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.
- jevdecision/__init__.py +51 -0
- jevdecision/__main__.py +87 -0
- jevdecision/answers.py +307 -0
- jevdecision/backends.py +275 -0
- jevdecision/core.py +71 -0
- jevdecision/errors.py +39 -0
- jevdecision/evaluation.py +598 -0
- jevdecision/py.typed +0 -0
- jevdecision/questions.py +164 -0
- jevdecision-0.1.0.dist-info/METADATA +364 -0
- jevdecision-0.1.0.dist-info/RECORD +15 -0
- jevdecision-0.1.0.dist-info/WHEEL +5 -0
- jevdecision-0.1.0.dist-info/entry_points.txt +3 -0
- jevdecision-0.1.0.dist-info/licenses/LICENSE +21 -0
- jevdecision-0.1.0.dist-info/top_level.txt +1 -0
jevdecision/__init__.py
ADDED
|
@@ -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
|
+
]
|
jevdecision/__main__.py
ADDED
|
@@ -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
|