jevdecision 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.
@@ -0,0 +1,51 @@
1
+ """jevdecision: Jev as the core decision model, with an OpenAI Decisions API backend.
2
+
3
+ A decision model answers bounded questions about a state with typed,
4
+ probability-bearing answers instead of generated text. This package defines the
5
+ questions and answers once and talks to either provider:
6
+
7
+ from jevdecision import Choice, DecisionModel, Noul
8
+
9
+ model = DecisionModel.from_env() # DECISION_BACKEND=jev (default) or openai
10
+ decision = model.decide(
11
+ "Help! My payouts have been failing for 3 days.",
12
+ [
13
+ Noul("refund", "Is the customer asking for a refund?"),
14
+ Choice("department", "Which team should handle this?", {
15
+ "billing": "Payments, invoicing, refunds",
16
+ "technical": "Bugs, outages, integrations",
17
+ }),
18
+ ],
19
+ )
20
+ decision["department"].choice
21
+ """
22
+
23
+ from .answers import ChoiceAnswer, Decision, NoulAnswer, RefusalAnswer, ScoreAnswer
24
+ from .backends import JevBackend, OpenAIDecisionsBackend, create_backend
25
+ from .core import DecisionModel
26
+ from .errors import APIError, DatasetError, DecisionError, QuestionError, ResponseError
27
+ from .questions import Choice, Noul, Predicate, Score, questions_from_spec
28
+
29
+ __version__ = "0.1.0"
30
+
31
+ __all__ = [
32
+ "APIError",
33
+ "Choice",
34
+ "ChoiceAnswer",
35
+ "DatasetError",
36
+ "Decision",
37
+ "DecisionError",
38
+ "DecisionModel",
39
+ "JevBackend",
40
+ "Noul",
41
+ "NoulAnswer",
42
+ "OpenAIDecisionsBackend",
43
+ "Predicate",
44
+ "QuestionError",
45
+ "ResponseError",
46
+ "RefusalAnswer",
47
+ "Score",
48
+ "ScoreAnswer",
49
+ "create_backend",
50
+ "questions_from_spec",
51
+ ]
@@ -0,0 +1,87 @@
1
+ """Command line: ask a question set about a state and print the decision as JSON.
2
+
3
+ python -m jevdecision --questions examples/support-ticket.json \
4
+ --state "Help! My payouts have been failing for 3 days."
5
+
6
+ `--dry-run` prints the provider request instead of sending it, so a question
7
+ set can be checked without an API key. Exit codes: 0 success, 1 provider or
8
+ response error, 2 invalid input.
9
+ """
10
+
11
+ import argparse
12
+ import json
13
+ import os
14
+ import sys
15
+ from pathlib import Path
16
+
17
+ from .answers import RefusalAnswer
18
+ from .backends import BACKENDS, _check_state, create_backend
19
+ from .core import BACKEND_ENV, DEFAULT_BACKEND, DecisionModel
20
+ from .errors import DecisionError
21
+ from .questions import check_question_set, questions_from_spec
22
+
23
+
24
+ def positive_seconds(text):
25
+ """argparse type for --timeout: a finite number of seconds above zero."""
26
+ try:
27
+ value = float(text)
28
+ except ValueError:
29
+ raise argparse.ArgumentTypeError(f"invalid number: {text!r}") from None
30
+ if not 0 < value < float("inf"):
31
+ raise argparse.ArgumentTypeError("must be a positive number of seconds")
32
+ return value
33
+
34
+
35
+ def parse_args(argv):
36
+ parser = argparse.ArgumentParser(prog="python -m jevdecision", description=__doc__.split("\n")[0])
37
+ parser.add_argument("--questions", required=True, type=Path,
38
+ help="JSON file in Jev's question map format")
39
+ source = parser.add_mutually_exclusive_group(required=True)
40
+ source.add_argument("--state", help="state text")
41
+ source.add_argument("--state-file", type=Path,
42
+ help="state file; a .json file is sent as a JSON object or list of strings")
43
+ parser.add_argument("--backend", choices=sorted(BACKENDS),
44
+ default=os.environ.get(BACKEND_ENV) or DEFAULT_BACKEND,
45
+ help=f"provider (default: ${BACKEND_ENV} or {DEFAULT_BACKEND})")
46
+ parser.add_argument("--model", help="model id (default: the backend's)")
47
+ parser.add_argument("--base-url", help="provider or gateway base URL")
48
+ parser.add_argument("--path", help="endpoint path, e.g. /v1/decisions on a gateway")
49
+ parser.add_argument("--timeout", type=positive_seconds, default=30.0, help="seconds (default: 30)")
50
+ parser.add_argument("--dry-run", action="store_true", help="print the request; send nothing")
51
+ return parser.parse_args(argv)
52
+
53
+
54
+ def load_state(args):
55
+ if args.state is not None:
56
+ return args.state
57
+ text = args.state_file.read_text(encoding="utf-8")
58
+ return json.loads(text) if args.state_file.suffix == ".json" else text
59
+
60
+
61
+ def main(argv=None):
62
+ args = parse_args(sys.argv[1:] if argv is None else argv)
63
+ try:
64
+ spec = json.loads(args.questions.read_text(encoding="utf-8"))
65
+ questions = check_question_set(questions_from_spec(spec))
66
+ state = _check_state(load_state(args))
67
+ backend = create_backend(args.backend, model=args.model, base_url=args.base_url,
68
+ path=args.path, timeout=args.timeout)
69
+ except (OSError, ValueError, DecisionError) as error:
70
+ # ValueError covers malformed JSON, undecodable text, and invalid questions.
71
+ print(f"error: {error}", file=sys.stderr)
72
+ return 2
73
+ if args.dry_run:
74
+ request = {"url": backend.url, "body": backend.build_request(state, questions)}
75
+ print(json.dumps(request, indent=2, sort_keys=True, ensure_ascii=False))
76
+ return 0
77
+ try:
78
+ decision = DecisionModel(backend).decide(state, questions)
79
+ except DecisionError as error:
80
+ print(f"error: {error}", file=sys.stderr)
81
+ return 1
82
+ print(json.dumps(decision.to_dict(), sort_keys=True, separators=(",", ":"), ensure_ascii=False))
83
+ return 1 if any(isinstance(a, RefusalAnswer) for a in decision.answers.values()) else 0
84
+
85
+
86
+ if __name__ == "__main__":
87
+ sys.exit(main())
jevdecision/answers.py ADDED
@@ -0,0 +1,307 @@
1
+ """Typed answers and the provider-neutral response parser.
2
+
3
+ Both providers return one answer per question with probabilities, but spell the
4
+ fields differently (Jev keys answers by question name and probabilities by
5
+ label; the OpenAI Decisions API may return a list of named answers and a list of
6
+ per-option probabilities). `parse_answers` accepts either shape and checks every
7
+ answer against the question it answers, so callers see one typed result.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from collections.abc import Iterator
13
+ import math
14
+ import re
15
+ from dataclasses import dataclass, field
16
+ from typing import Any, Optional
17
+
18
+ from .errors import ResponseError
19
+ from .questions import Choice, Noul, Score
20
+
21
+ # Retain the existing rounding tolerance for numeric score indices only.
22
+ PROBABILITY_SLACK = 1e-6
23
+ LABEL_KEYS = ("value", "label", "choice", "level", "name")
24
+ LEVEL_INDEX_RE = re.compile(r"[0-9]+")
25
+ PROBABILITY_KEYS = ("probability", "p", "prob")
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class RefusalAnswer:
30
+ """A provider declined this question; sibling answers remain usable."""
31
+ name: str
32
+ type = "refusal"
33
+
34
+ def to_dict(self) -> dict[str, Any]:
35
+ return {"type": self.type}
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class NoulAnswer:
40
+ name: str
41
+ probability: float
42
+ type = "noul"
43
+
44
+ def is_yes(self, threshold: float = 0.5) -> bool:
45
+ return self.probability >= threshold
46
+
47
+ def to_dict(self) -> dict[str, Any]:
48
+ return {"type": self.type, "probability": self.probability}
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class ChoiceAnswer:
53
+ name: str
54
+ choice: str
55
+ probabilities: "dict[str, float]"
56
+ confidence: Optional[float] = None
57
+ type = "choice"
58
+
59
+ @property
60
+ def probability(self) -> Optional[float]:
61
+ return self.probabilities.get(self.choice)
62
+
63
+ def ranked(self) -> list[tuple[str, float]]:
64
+ """Labels with their probabilities, most likely first (ties keep option order)."""
65
+ return sorted(self.probabilities.items(), key=lambda item: -item[1])
66
+
67
+ def to_dict(self) -> dict[str, Any]:
68
+ return {
69
+ "type": self.type,
70
+ "choice": self.choice,
71
+ "probabilities": dict(self.probabilities),
72
+ "confidence": self.confidence,
73
+ }
74
+
75
+
76
+ @dataclass(frozen=True)
77
+ class ScoreAnswer:
78
+ name: str
79
+ score: float
80
+ levels: "tuple[str, ...]"
81
+ probabilities: "tuple[Optional[float], ...]"
82
+ confidence: Optional[float] = None
83
+ type = "score"
84
+
85
+ @property
86
+ def level(self) -> str:
87
+ """The level nearest to the score."""
88
+ index = min(max(int(math.floor(self.score + 0.5)), 0), len(self.levels) - 1)
89
+ return self.levels[index]
90
+
91
+ @property
92
+ def probability(self) -> Optional[float]:
93
+ return self.probabilities[self.levels.index(self.level)]
94
+
95
+ @property
96
+ def normalized(self) -> float:
97
+ """The score rescaled to [0, 1]."""
98
+ return self.score / (len(self.levels) - 1)
99
+
100
+ def to_dict(self) -> dict[str, Any]:
101
+ return {
102
+ "type": self.type,
103
+ "score": self.score,
104
+ "level": self.level,
105
+ "levels": list(self.levels),
106
+ "probabilities": list(self.probabilities),
107
+ "confidence": self.confidence,
108
+ }
109
+
110
+
111
+ @dataclass(frozen=True)
112
+ class Decision:
113
+ """All answers for one request, keyed by question name."""
114
+
115
+ answers: "dict[str, Answer]"
116
+ backend: str
117
+ model: Optional[str] = None
118
+ usage: "dict[str, Any]" = field(default_factory=dict)
119
+ id: Optional[str] = None
120
+ raw: "dict[str, Any]" = field(default_factory=dict, repr=False, compare=False)
121
+
122
+ def __getitem__(self, name: str) -> Answer:
123
+ return self.answers[name]
124
+
125
+ def __iter__(self) -> Iterator[str]:
126
+ return iter(self.answers)
127
+
128
+ def __len__(self) -> int:
129
+ return len(self.answers)
130
+
131
+ def to_dict(self) -> dict[str, Any]:
132
+ from . import __version__
133
+ return {
134
+ "schema_version": 1,
135
+ "produced_by": {"name": "jevdecision", "version": __version__},
136
+ "backend": self.backend,
137
+ "model": self.model,
138
+ "id": self.id,
139
+ "usage": dict(self.usage),
140
+ "answers": {name: answer.to_dict() for name, answer in self.answers.items()},
141
+ }
142
+
143
+
144
+ # Parsing -------------------------------------------------------------------
145
+
146
+ def _number(value, where):
147
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
148
+ raise ResponseError(f"{where}: expected a number, got {value!r}")
149
+ value = float(value)
150
+ if not math.isfinite(value):
151
+ raise ResponseError(f"{where}: expected a finite number, got {value!r}")
152
+ return value
153
+
154
+
155
+ def _probability(value, where):
156
+ value = _number(value, where)
157
+ if not 0 <= value <= 1:
158
+ raise ResponseError(f"{where}: probability {value} is outside [0, 1]")
159
+ return min(max(value, 0.0), 1.0)
160
+
161
+
162
+ def _first(entry, keys):
163
+ for key in keys:
164
+ if key in entry and entry[key] is not None:
165
+ return entry[key]
166
+ return None
167
+
168
+
169
+ def _probability_map(raw, where):
170
+ """Return {key: probability} from a mapping, or a list of {label, probability} objects."""
171
+ if isinstance(raw, dict):
172
+ return {str(key): _probability(value, f"{where}[{key!r}]") for key, value in raw.items()}
173
+ if isinstance(raw, list):
174
+ result = {}
175
+ for index, item in enumerate(raw):
176
+ if not isinstance(item, dict):
177
+ raise ResponseError(f"{where}[{index}]: expected an object, got {item!r}")
178
+ key = _first(item, LABEL_KEYS)
179
+ value = _first(item, PROBABILITY_KEYS)
180
+ if key is None or value is None:
181
+ raise ResponseError(f"{where}[{index}]: needs a label and a probability")
182
+ if not isinstance(key, (str, int)) or isinstance(key, bool):
183
+ raise ResponseError(f"{where}[{index}]: label must be a string or an index, got {key!r}")
184
+ if str(key) in result:
185
+ raise ResponseError(f"{where}: label {str(key)!r} appears more than once")
186
+ result[str(key)] = _probability(value, f"{where}[{key!r}]")
187
+ return result
188
+ raise ResponseError(f"{where}: expected an object or a list, got {raw!r}")
189
+
190
+
191
+ def _distribution(values, where):
192
+ values = tuple(values)
193
+ if abs(sum(values) - 1) > 1e-6 * len(values) + 1e-3:
194
+ raise ResponseError(f"{where}: probability distribution must sum to 1")
195
+
196
+
197
+ def _confidence(entry, where):
198
+ value = entry.get("confidence")
199
+ return None if value is None else _probability(value, f"{where}.confidence")
200
+
201
+
202
+ def _parse_noul(question, entry, where):
203
+ value = _first(entry, ("noul", "probability", "predicate", "value"))
204
+ if value is None:
205
+ raise ResponseError(f"{where}: missing the probability of yes")
206
+ return NoulAnswer(question.name, _probability(value, where))
207
+
208
+
209
+ def _parse_choice(question, entry, where):
210
+ labels = question.labels
211
+ choice = _first(entry, ("choice", "value", "label"))
212
+ if not isinstance(choice, str) or choice not in question.options:
213
+ raise ResponseError(f"{where}: choice {choice!r} is not one of {list(labels)}")
214
+ raw = entry.get("probabilities")
215
+ probabilities = _probability_map(raw, f"{where}.probabilities") if raw is not None else {}
216
+ unknown = set(probabilities) - set(labels)
217
+ if unknown:
218
+ raise ResponseError(f"{where}: probabilities name unknown options {sorted(unknown)}")
219
+ if len(probabilities) == len(labels):
220
+ _distribution(probabilities.values(), where)
221
+ ordered = {label: probabilities[label] for label in labels if label in probabilities}
222
+ return ChoiceAnswer(question.name, choice, ordered, _confidence(entry, where))
223
+
224
+
225
+ def _parse_score(question, entry, where):
226
+ levels = question.levels
227
+ raw = entry.get("probabilities")
228
+ probabilities = [None] * len(levels)
229
+ if isinstance(raw, list) and all(not isinstance(item, dict) for item in raw):
230
+ if len(raw) > len(levels):
231
+ raise ResponseError(f"{where}: expected {len(levels)} probabilities, got {len(raw)}")
232
+ probabilities[:len(raw)] = [None if value is None else _probability(value, f"{where}.probabilities[{i}]")
233
+ for i, value in enumerate(raw)]
234
+ elif raw is not None:
235
+ named = set()
236
+ for key, value in _probability_map(raw, f"{where}.probabilities").items():
237
+ if key in levels:
238
+ index = levels.index(key)
239
+ elif LEVEL_INDEX_RE.fullmatch(key) and int(key) < len(levels):
240
+ index = int(key)
241
+ else:
242
+ raise ResponseError(f"{where}: probabilities name unknown level {key!r}")
243
+ if index in named:
244
+ raise ResponseError(f"{where}: probabilities name level {levels[index]!r} more than once")
245
+ named.add(index)
246
+ probabilities[index] = value
247
+ complete = all(p is not None for p in probabilities)
248
+ if complete:
249
+ _distribution(probabilities, where)
250
+ score = entry.get("score")
251
+ if score is None:
252
+ if not complete:
253
+ raise ResponseError(f"{where}: missing score; deriving it requires a complete probability distribution")
254
+ total = sum(probabilities)
255
+ if total <= 0:
256
+ raise ResponseError(f"{where}: probabilities are all zero")
257
+ score = sum(i * p for i, p in enumerate(probabilities)) / total
258
+ score = _number(score, f"{where}.score")
259
+ if not -PROBABILITY_SLACK <= score <= len(levels) - 1 + PROBABILITY_SLACK:
260
+ raise ResponseError(f"{where}: score {score} is outside [0, {len(levels) - 1}]")
261
+ score = min(max(score, 0.0), float(len(levels) - 1))
262
+ return ScoreAnswer(question.name, score, levels, tuple(probabilities), _confidence(entry, where))
263
+
264
+
265
+ PARSERS = {Noul: _parse_noul, Choice: _parse_choice, Score: _parse_score}
266
+ TYPE_ALIASES = {"noul": "noul", "predicate": "noul", "choice": "choice", "score": "score"}
267
+
268
+
269
+ def _entries_by_name(raw_answers):
270
+ if isinstance(raw_answers, dict):
271
+ return raw_answers
272
+ if isinstance(raw_answers, list):
273
+ entries = {}
274
+ for index, entry in enumerate(raw_answers):
275
+ if not isinstance(entry, dict) or not isinstance(entry.get("name"), str):
276
+ raise ResponseError(f"answers[{index}]: expected an object with a name")
277
+ if entry["name"] in entries:
278
+ raise ResponseError(f"answers: duplicate answer for {entry['name']!r}")
279
+ entries[entry["name"]] = entry
280
+ return entries
281
+ raise ResponseError(f"answers: expected an object or a list, got {type(raw_answers).__name__}")
282
+
283
+
284
+ def parse_answers(raw_answers, questions):
285
+ """Return {name: answer} for `questions`; raise ResponseError on any mismatch."""
286
+ entries = _entries_by_name(raw_answers)
287
+ asked = {question.name for question in questions}
288
+ extra = set(entries) - asked
289
+ if extra:
290
+ raise ResponseError(f"answers: unexpected answers for {sorted(extra)}")
291
+ answers = {}
292
+ for question in questions:
293
+ where = f"answers[{question.name!r}]"
294
+ entry = entries.get(question.name)
295
+ if not isinstance(entry, dict):
296
+ raise ResponseError(f"{where}: missing or not an object")
297
+ kind = entry.get("type")
298
+ if kind == "refusal":
299
+ answers[question.name] = RefusalAnswer(question.name)
300
+ continue
301
+ if kind is not None and (not isinstance(kind, str) or TYPE_ALIASES.get(kind) != question.type):
302
+ raise ResponseError(f"{where}: answered as {kind!r}, asked as {question.type!r}")
303
+ answers[question.name] = PARSERS[type(question)](question, entry, where)
304
+ return answers
305
+
306
+
307
+ Answer = NoulAnswer | ChoiceAnswer | ScoreAnswer | RefusalAnswer