flyteplugins-typesafe-ai 0.0.0a0__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.
- flyteplugins/typesafe_ai/__init__.py +105 -0
- flyteplugins/typesafe_ai/_ask.py +341 -0
- flyteplugins/typesafe_ai/_client.py +50 -0
- flyteplugins/typesafe_ai/_docs.py +78 -0
- flyteplugins/typesafe_ai/_types.py +91 -0
- flyteplugins_typesafe_ai-0.0.0a0.dist-info/METADATA +275 -0
- flyteplugins_typesafe_ai-0.0.0a0.dist-info/RECORD +10 -0
- flyteplugins_typesafe_ai-0.0.0a0.dist-info/WHEEL +5 -0
- flyteplugins_typesafe_ai-0.0.0a0.dist-info/entry_points.txt +2 -0
- flyteplugins_typesafe_ai-0.0.0a0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Run TypeSafe's System One model (Jev) inside durable Flyte tasks.
|
|
2
|
+
|
|
3
|
+
Jev answers typed questions in parallel instead of generating text, and returns
|
|
4
|
+
calibrated confidence with every answer. This plugin gives those answers a shape
|
|
5
|
+
that crosses a Flyte task boundary — `Choice`, `Score` and `Noul` — and a way to
|
|
6
|
+
ask a whole battery of them in a single request.
|
|
7
|
+
|
|
8
|
+
```python
|
|
9
|
+
import enum
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
|
|
12
|
+
import flyte
|
|
13
|
+
from flyteplugins.typesafe_ai import Choice, Noul, Score, ask
|
|
14
|
+
|
|
15
|
+
env = flyte.TaskEnvironment(
|
|
16
|
+
"triage",
|
|
17
|
+
secrets=[flyte.Secret(key="TYPESAFE_API_KEY", as_env_var="TYPESAFE_API_KEY")],
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Intent(enum.Enum):
|
|
22
|
+
'''Which intent best fits the ticket?'''
|
|
23
|
+
|
|
24
|
+
REFUND = "refund"
|
|
25
|
+
'''they want money back'''
|
|
26
|
+
DELIVERY = "delivery"
|
|
27
|
+
'''they are asking where their order is'''
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Severity(enum.IntEnum):
|
|
31
|
+
'''How badly is this customer affected?'''
|
|
32
|
+
|
|
33
|
+
NONE = 0
|
|
34
|
+
'''no impact; a question or a comment'''
|
|
35
|
+
MINOR = 1
|
|
36
|
+
'''inconvenient, but they can carry on'''
|
|
37
|
+
SERIOUS = 2
|
|
38
|
+
'''they are blocked'''
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class Triage:
|
|
43
|
+
# The enums above document themselves, so these fields need no metadata at all.
|
|
44
|
+
intent: Choice[Intent]
|
|
45
|
+
severity: Score[Severity]
|
|
46
|
+
# A Noul has no vocabulary to document itself with, so it needs both.
|
|
47
|
+
hostile: Noul = field(
|
|
48
|
+
metadata={"question": "Is the customer hostile?", "criteria": {"true": "insults or threats", "false": "civil"}}
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@env.task
|
|
53
|
+
async def triage(ticket: str) -> Triage:
|
|
54
|
+
return await ask(Triage, {"ticket": ticket})
|
|
55
|
+
```
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
import functools
|
|
59
|
+
|
|
60
|
+
from flyte.types import TypeEngine
|
|
61
|
+
|
|
62
|
+
from ._ask import BatteryError, ask, ask_with_info, compile_questions
|
|
63
|
+
from ._client import API_KEY_ENV, MissingAPIKey, client
|
|
64
|
+
from ._types import CRITERIA_KEY, QUESTION_KEY, CallInfo, Choice, Noul, Score
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@functools.lru_cache(maxsize=None)
|
|
68
|
+
def register_typesafe_ai_types():
|
|
69
|
+
"""Register Choice, Score and Noul with the Flyte type engine.
|
|
70
|
+
|
|
71
|
+
Called automatically via the `flyte.plugins.types` entry point when
|
|
72
|
+
`flyte.init()` runs with `load_plugin_type_transformers=True` (the default).
|
|
73
|
+
|
|
74
|
+
The three answer types are plain dataclasses, so they reuse the existing
|
|
75
|
+
`DataclassTransformer` rather than introducing one of their own. Registering
|
|
76
|
+
them anyway makes the resolution explicit: a parameterized `Choice[Intent]`
|
|
77
|
+
is matched through its origin instead of falling through to the type engine's
|
|
78
|
+
last-resort dataclass branch.
|
|
79
|
+
"""
|
|
80
|
+
from flyte.types._type_engine import DataclassTransformer
|
|
81
|
+
|
|
82
|
+
transformer = DataclassTransformer()
|
|
83
|
+
for answer_type in (Choice, Score, Noul):
|
|
84
|
+
TypeEngine.register_additional_type(transformer, answer_type)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# Also register at import time, so the types work without flyte.init() -- a unit
|
|
88
|
+
# test that only round-trips a battery never calls it.
|
|
89
|
+
register_typesafe_ai_types()
|
|
90
|
+
|
|
91
|
+
__all__ = [
|
|
92
|
+
"API_KEY_ENV",
|
|
93
|
+
"CRITERIA_KEY",
|
|
94
|
+
"QUESTION_KEY",
|
|
95
|
+
"BatteryError",
|
|
96
|
+
"CallInfo",
|
|
97
|
+
"Choice",
|
|
98
|
+
"MissingAPIKey",
|
|
99
|
+
"Noul",
|
|
100
|
+
"Score",
|
|
101
|
+
"ask",
|
|
102
|
+
"ask_with_info",
|
|
103
|
+
"client",
|
|
104
|
+
"compile_questions",
|
|
105
|
+
]
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
"""Turn typed questions into one System One call, and back again.
|
|
2
|
+
|
|
3
|
+
The whole point of System One is that questions are answered in parallel and in
|
|
4
|
+
isolation, so asking forty of them costs about what asking three costs. That only
|
|
5
|
+
pays off if asking forty is as easy to write as asking three.
|
|
6
|
+
|
|
7
|
+
There are three ways to say what you want, and they compile to the same request:
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
await ask(Triage, state) # a battery dataclass
|
|
11
|
+
await ask(Choice[Intent], state) # a single question
|
|
12
|
+
await ask({"intent": Choice[Intent], "hostile": Noul}, state) # an ad-hoc battery
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
In every form the vocabulary documents itself: an enum's class docstring is the
|
|
16
|
+
question and its member docstrings are the criteria. Override either with metadata
|
|
17
|
+
-- `Annotated[Choice[Intent], {"question": ...}]` anywhere, or
|
|
18
|
+
`field(metadata={...})` on a dataclass field, which wins over the annotation
|
|
19
|
+
because it is the more specific place to say it.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import dataclasses
|
|
25
|
+
import enum
|
|
26
|
+
import time
|
|
27
|
+
import typing
|
|
28
|
+
from typing import Any, Dict, Mapping, Optional, Sequence, Tuple, Type, TypeVar, Union
|
|
29
|
+
|
|
30
|
+
from ._client import client as make_client
|
|
31
|
+
from ._docs import class_doc, member_docs
|
|
32
|
+
from ._types import CRITERIA_KEY, QUESTION_KEY, CallInfo, Choice, Noul, Score
|
|
33
|
+
|
|
34
|
+
B = TypeVar("B")
|
|
35
|
+
|
|
36
|
+
#: What you can hand to ask(): a battery dataclass, one question type, or a mapping.
|
|
37
|
+
Askable = Union[Type[Any], Mapping[str, Any]]
|
|
38
|
+
|
|
39
|
+
#: The name a single, unnamed question is asked under.
|
|
40
|
+
SINGLE = "answer"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class BatteryError(TypeError):
|
|
44
|
+
"""The questions cannot be compiled. Always a problem in your code."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclasses.dataclass(frozen=True)
|
|
48
|
+
class _Spec:
|
|
49
|
+
"""One question, however it was declared."""
|
|
50
|
+
|
|
51
|
+
name: str
|
|
52
|
+
kind: str # choice | score | noul
|
|
53
|
+
type: Any # the bare Choice[...] / Score[...] / Noul
|
|
54
|
+
enum_cls: Optional[Type[enum.Enum]]
|
|
55
|
+
meta: Mapping[str, Any]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _unwrap(tp: Any) -> Tuple[Any, Mapping[str, Any]]:
|
|
59
|
+
"""Split Annotated[X, {...}] into X and the metadata mapping it carries."""
|
|
60
|
+
if typing.get_origin(tp) is not None and hasattr(tp, "__metadata__"):
|
|
61
|
+
bare = typing.get_args(tp)[0]
|
|
62
|
+
meta: Dict[str, Any] = {}
|
|
63
|
+
for extra in tp.__metadata__:
|
|
64
|
+
if isinstance(extra, Mapping):
|
|
65
|
+
meta.update(extra)
|
|
66
|
+
elif isinstance(extra, str):
|
|
67
|
+
meta.setdefault(QUESTION_KEY, extra)
|
|
68
|
+
return bare, meta
|
|
69
|
+
return tp, {}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _kind(tp: Any) -> Optional[str]:
|
|
73
|
+
origin = typing.get_origin(tp) or tp
|
|
74
|
+
if origin is Noul:
|
|
75
|
+
return "noul"
|
|
76
|
+
if origin is Choice:
|
|
77
|
+
return "choice"
|
|
78
|
+
if origin is Score:
|
|
79
|
+
return "score"
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _enum_arg(tp: Any) -> Optional[Type[enum.Enum]]:
|
|
84
|
+
args = typing.get_args(tp)
|
|
85
|
+
if args and isinstance(args[0], type) and issubclass(args[0], enum.Enum):
|
|
86
|
+
return args[0]
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _spec(name: str, tp: Any, extra_meta: Optional[Mapping[str, Any]] = None) -> _Spec:
|
|
91
|
+
bare, annotated_meta = _unwrap(tp)
|
|
92
|
+
kind = _kind(bare)
|
|
93
|
+
if kind is None:
|
|
94
|
+
raise BatteryError(
|
|
95
|
+
f"'{name}' is typed {tp!r}, which is not a question. It must be "
|
|
96
|
+
"Choice[SomeEnum], Score[SomeIntEnum] or Noul."
|
|
97
|
+
)
|
|
98
|
+
# field metadata beats the annotation: it is the more specific place to say it.
|
|
99
|
+
meta = {**annotated_meta, **(extra_meta or {})}
|
|
100
|
+
enum_cls = _enum_arg(bare)
|
|
101
|
+
if kind == "choice" and enum_cls is None:
|
|
102
|
+
raise BatteryError(f"Choice '{name}' needs an Enum type argument, e.g. Choice[Intent].")
|
|
103
|
+
if kind == "score" and (enum_cls is None or not issubclass(enum_cls, enum.IntEnum)):
|
|
104
|
+
raise BatteryError(f"Score '{name}' needs an IntEnum type argument, e.g. Score[Severity].")
|
|
105
|
+
return _Spec(name=name, kind=kind, type=bare, enum_cls=enum_cls, meta=meta)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _specs(askable: Askable) -> list[_Spec]:
|
|
109
|
+
"""Normalise every accepted form into one list of questions."""
|
|
110
|
+
if isinstance(askable, Mapping):
|
|
111
|
+
return [_spec(name, tp) for name, tp in askable.items()]
|
|
112
|
+
|
|
113
|
+
# Check for a question type before the dataclass branch: the answer types are
|
|
114
|
+
# themselves dataclasses, so a bare `Noul` would otherwise look like an empty battery.
|
|
115
|
+
if _kind(_unwrap(askable)[0]) is not None:
|
|
116
|
+
return [_spec(SINGLE, askable)]
|
|
117
|
+
|
|
118
|
+
if dataclasses.is_dataclass(askable) and isinstance(askable, type):
|
|
119
|
+
hints = typing.get_type_hints(askable, include_extras=True)
|
|
120
|
+
specs = []
|
|
121
|
+
for f in dataclasses.fields(askable):
|
|
122
|
+
tp = hints[f.name]
|
|
123
|
+
bare, _ = _unwrap(tp)
|
|
124
|
+
if _kind(bare) is None:
|
|
125
|
+
if QUESTION_KEY in f.metadata: # a question was intended, the type is wrong
|
|
126
|
+
raise BatteryError(
|
|
127
|
+
f"Field '{f.name}' is typed {tp!r}, which is not a question. It must be "
|
|
128
|
+
"Choice[SomeEnum], Score[SomeIntEnum] or Noul."
|
|
129
|
+
)
|
|
130
|
+
continue # an ordinary field, carried along but never asked
|
|
131
|
+
specs.append(_spec(f.name, tp, f.metadata))
|
|
132
|
+
if not specs:
|
|
133
|
+
raise BatteryError(
|
|
134
|
+
f"{askable.__name__} has no question fields. A question field is typed "
|
|
135
|
+
"Choice[SomeEnum], Score[SomeIntEnum] or Noul."
|
|
136
|
+
)
|
|
137
|
+
# ask() builds the battery out of answers alone, so any other field has to be
|
|
138
|
+
# able to default itself. Without this the failure is a bare TypeError from
|
|
139
|
+
# __init__ that never mentions the battery.
|
|
140
|
+
unfillable = [
|
|
141
|
+
f.name
|
|
142
|
+
for f in dataclasses.fields(askable)
|
|
143
|
+
if f.name not in {sp.name for sp in specs}
|
|
144
|
+
and f.init
|
|
145
|
+
and f.default is dataclasses.MISSING
|
|
146
|
+
and f.default_factory is dataclasses.MISSING
|
|
147
|
+
]
|
|
148
|
+
if unfillable:
|
|
149
|
+
raise BatteryError(
|
|
150
|
+
f"{askable.__name__} has non-question field(s) {unfillable} with no default. "
|
|
151
|
+
"ask() constructs the battery from the answers, so every field it does not ask "
|
|
152
|
+
"for needs a default."
|
|
153
|
+
)
|
|
154
|
+
return specs
|
|
155
|
+
|
|
156
|
+
return [_spec(SINGLE, askable)] # not a battery and not a question: _spec explains why
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _instructions(spec: _Spec) -> str:
|
|
160
|
+
"""The question text: what was written here, or what the vocabulary already says."""
|
|
161
|
+
asked = spec.meta.get(QUESTION_KEY)
|
|
162
|
+
if isinstance(asked, str) and asked.strip():
|
|
163
|
+
return asked
|
|
164
|
+
inherited = class_doc(spec.enum_cls) if spec.enum_cls is not None else None
|
|
165
|
+
if inherited:
|
|
166
|
+
return inherited
|
|
167
|
+
hint = (
|
|
168
|
+
"A Noul has no vocabulary to document itself with, so it always needs one."
|
|
169
|
+
if spec.kind == "noul"
|
|
170
|
+
else f"Give {spec.enum_cls.__name__} a class docstring, or say it here." # type: ignore[union-attr]
|
|
171
|
+
)
|
|
172
|
+
raise BatteryError(f"'{spec.name}' has no question. {hint}")
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _described(enum_cls: Type[enum.Enum]) -> Dict[str, Optional[str]]:
|
|
176
|
+
docs = member_docs(enum_cls)
|
|
177
|
+
return {m.name: docs.get(m.name) for m in enum_cls}
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _choice_criteria(spec: _Spec) -> Dict[str, Optional[str]]:
|
|
181
|
+
"""Keyed by member name -- the same string Flyte puts on the wire for an enum."""
|
|
182
|
+
enum_cls = typing.cast(Type[enum.Enum], spec.enum_cls)
|
|
183
|
+
criteria = _described(enum_cls)
|
|
184
|
+
given = spec.meta.get(CRITERIA_KEY)
|
|
185
|
+
if given is None:
|
|
186
|
+
return criteria
|
|
187
|
+
if not isinstance(given, Mapping):
|
|
188
|
+
raise BatteryError(f"Choice '{spec.name}' needs its criteria as a mapping of option -> description.")
|
|
189
|
+
for key, text in given.items():
|
|
190
|
+
member = key.name if isinstance(key, enum.Enum) else str(key)
|
|
191
|
+
if member not in criteria:
|
|
192
|
+
raise BatteryError(f"Choice '{spec.name}': '{member}' is not a member of {enum_cls.__name__}.")
|
|
193
|
+
criteria[member] = text
|
|
194
|
+
return criteria
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _score_criteria(spec: _Spec) -> list:
|
|
198
|
+
"""Positional, one rung per step -- which is what the SDK's Score takes."""
|
|
199
|
+
enum_cls = typing.cast(Type[enum.IntEnum], spec.enum_cls)
|
|
200
|
+
members = sorted(enum_cls, key=lambda m: m.value)
|
|
201
|
+
expected = list(range(len(members)))
|
|
202
|
+
if [m.value for m in members] != expected:
|
|
203
|
+
raise BatteryError(
|
|
204
|
+
f"Score '{spec.name}' uses {enum_cls.__name__}, whose values are {[m.value for m in members]}. "
|
|
205
|
+
f"A rubric is positional, so the members must be {expected} -- one rung per step, starting at zero."
|
|
206
|
+
)
|
|
207
|
+
given = spec.meta.get(CRITERIA_KEY)
|
|
208
|
+
if isinstance(given, Mapping):
|
|
209
|
+
given = {(k.name if isinstance(k, enum.Enum) else str(k)): v for k, v in given.items()}
|
|
210
|
+
unknown = set(given) - {m.name for m in members}
|
|
211
|
+
if unknown:
|
|
212
|
+
raise BatteryError(f"Score '{spec.name}': {sorted(unknown)} are not members of {enum_cls.__name__}.")
|
|
213
|
+
elif isinstance(given, Sequence) and not isinstance(given, str):
|
|
214
|
+
if len(given) != len(members):
|
|
215
|
+
raise BatteryError(
|
|
216
|
+
f"Score '{spec.name}' has {len(given)} criteria for {len(members)} rungs. "
|
|
217
|
+
"A positional rubric needs one entry per member."
|
|
218
|
+
)
|
|
219
|
+
return list(given)
|
|
220
|
+
elif given is not None:
|
|
221
|
+
raise BatteryError(f"Score '{spec.name}' needs its criteria as a sequence of rungs, or a mapping.")
|
|
222
|
+
else:
|
|
223
|
+
given = {}
|
|
224
|
+
|
|
225
|
+
docs = _described(enum_cls)
|
|
226
|
+
return [given.get(m.name) or docs.get(m.name) or m.name.replace("_", " ").lower() for m in members]
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def compile_questions(askable: Askable) -> Dict[str, Any]:
|
|
230
|
+
"""Build the SDK's question objects from any accepted form."""
|
|
231
|
+
import typesafe_sdk as ts
|
|
232
|
+
|
|
233
|
+
questions: Dict[str, Any] = {}
|
|
234
|
+
for spec in _specs(askable):
|
|
235
|
+
instructions = _instructions(spec)
|
|
236
|
+
if spec.kind == "noul":
|
|
237
|
+
given = spec.meta.get(CRITERIA_KEY)
|
|
238
|
+
criteria = None
|
|
239
|
+
if given is not None:
|
|
240
|
+
if not isinstance(given, Mapping):
|
|
241
|
+
raise BatteryError(f"Noul '{spec.name}' needs criteria like {{'true': ..., 'false': ...}}.")
|
|
242
|
+
criteria = ts.NoulCriteria(true=given.get("true"), false=given.get("false"))
|
|
243
|
+
questions[spec.name] = ts.Noul(instructions=instructions, criteria=criteria)
|
|
244
|
+
elif spec.kind == "choice":
|
|
245
|
+
questions[spec.name] = ts.Choice(instructions=instructions, criteria=_choice_criteria(spec))
|
|
246
|
+
else:
|
|
247
|
+
questions[spec.name] = ts.Score(instructions=instructions, criteria=_score_criteria(spec))
|
|
248
|
+
return questions
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _by_member_name(enum_cls: Type[enum.Enum], probabilities: Dict[str, float]) -> Dict[str, float]:
|
|
252
|
+
"""Re-key a rung-indexed distribution by member name, leaving unknown keys alone."""
|
|
253
|
+
by_value = {str(m.value): m.name for m in enum_cls}
|
|
254
|
+
return {by_value.get(k, k): v for k, v in probabilities.items()}
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _probabilities(raw: Any) -> Dict[str, float]:
|
|
258
|
+
if not raw:
|
|
259
|
+
return {}
|
|
260
|
+
return {str(k): float(v) for k, v in dict(raw).items()}
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _answer(spec: _Spec, answer: Any) -> Any:
|
|
264
|
+
if answer is None:
|
|
265
|
+
raise BatteryError(f"System 1 returned no answer for '{spec.name}'.")
|
|
266
|
+
if spec.kind == "noul":
|
|
267
|
+
return Noul(value=float(answer.noul))
|
|
268
|
+
enum_cls = typing.cast(Type[enum.Enum], spec.enum_cls)
|
|
269
|
+
confidence = float(getattr(answer, "confidence", 0.0) or 0.0)
|
|
270
|
+
probabilities = _probabilities(getattr(answer, "probabilities", None))
|
|
271
|
+
if spec.kind == "choice":
|
|
272
|
+
return Choice(value=enum_cls[answer.choice], confidence=confidence, probabilities=probabilities)
|
|
273
|
+
position = float(answer.score)
|
|
274
|
+
rung = min(max(round(position), 0), len(list(enum_cls)) - 1)
|
|
275
|
+
return Score(
|
|
276
|
+
value=enum_cls(rung),
|
|
277
|
+
position=position,
|
|
278
|
+
confidence=confidence,
|
|
279
|
+
# The SDK keys a Score's distribution by rung index; name them, so that both
|
|
280
|
+
# Choice.probabilities and Score.probabilities read as {member name: p}.
|
|
281
|
+
probabilities=_by_member_name(enum_cls, probabilities),
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _assemble(askable: Askable, specs: list[_Spec], answers: Mapping[str, Any]) -> Any:
|
|
286
|
+
"""Shape the answers like the thing that was asked."""
|
|
287
|
+
built = {spec.name: _answer(spec, answers.get(spec.name)) for spec in specs}
|
|
288
|
+
if isinstance(askable, Mapping):
|
|
289
|
+
return built
|
|
290
|
+
if dataclasses.is_dataclass(askable) and isinstance(askable, type):
|
|
291
|
+
return askable(**built)
|
|
292
|
+
return built[SINGLE]
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _answers_of(resp: Any) -> Dict[str, Any]:
|
|
296
|
+
answers = getattr(resp, "answers", None)
|
|
297
|
+
if answers is None: # older/newer shapes expose the three groups instead
|
|
298
|
+
answers = {**(resp.nouls or {}), **(resp.choices or {}), **(resp.scores or {})}
|
|
299
|
+
return dict(answers)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
async def ask_with_info(
|
|
303
|
+
askable: Askable,
|
|
304
|
+
state: Any,
|
|
305
|
+
*,
|
|
306
|
+
model: Optional[str] = None,
|
|
307
|
+
client: Any = None,
|
|
308
|
+
) -> Tuple[Any, CallInfo]:
|
|
309
|
+
"""Answer everything in one call, and report what the call cost."""
|
|
310
|
+
specs = _specs(askable)
|
|
311
|
+
questions = compile_questions(askable)
|
|
312
|
+
own = client is None
|
|
313
|
+
client = client or make_client(model=model)
|
|
314
|
+
started = time.perf_counter()
|
|
315
|
+
try:
|
|
316
|
+
resp = await client.system_one(state=state, questions=questions)
|
|
317
|
+
finally:
|
|
318
|
+
if own:
|
|
319
|
+
await client.aclose()
|
|
320
|
+
usage = getattr(resp, "usage", None)
|
|
321
|
+
info = CallInfo(
|
|
322
|
+
model=str(getattr(resp, "model", "") or ""),
|
|
323
|
+
questions=len(questions),
|
|
324
|
+
input_tokens=int(getattr(usage, "input_tokens", 0) or 0),
|
|
325
|
+
output_tokens=int(getattr(usage, "output_tokens", 0) or 0),
|
|
326
|
+
latency_s=round(time.perf_counter() - started, 3),
|
|
327
|
+
)
|
|
328
|
+
return _assemble(askable, specs, _answers_of(resp)), info
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
async def ask(askable: Askable, state: Any, *, model: Optional[str] = None, client: Any = None) -> Any:
|
|
332
|
+
"""Answer a battery, a single question, or a mapping of them -- in one call.
|
|
333
|
+
|
|
334
|
+
```python
|
|
335
|
+
triage = await ask(Triage, {"ticket": text}) # -> Triage
|
|
336
|
+
intent = await ask(Choice[Intent], {"ticket": text}) # -> Choice[Intent]
|
|
337
|
+
both = await ask({"intent": Choice[Intent], "hot": Noul}, s) # -> dict
|
|
338
|
+
```
|
|
339
|
+
"""
|
|
340
|
+
answered, _ = await ask_with_info(askable, state, model=model, client=client)
|
|
341
|
+
return answered
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Reaching TypeSafe from a Flyte task, and saying so clearly when you can't."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from typing import Any, Optional
|
|
7
|
+
|
|
8
|
+
#: The environment variable the TypeSafe SDK reads. Mount your secret as this:
|
|
9
|
+
#:
|
|
10
|
+
#: flyte.Secret(key="TYPESAFE_API_KEY", as_env_var="TYPESAFE_API_KEY")
|
|
11
|
+
#:
|
|
12
|
+
#: `flyte.Secret` derives `as_env_var` from the key by upper-casing it and
|
|
13
|
+
#: swapping `-` for `_`, so a key named TYPESAFE_API_KEY mounts correctly on its
|
|
14
|
+
#: own -- spelling it out just makes the env var greppable.
|
|
15
|
+
API_KEY_ENV = "TYPESAFE_API_KEY"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class MissingAPIKey(RuntimeError):
|
|
19
|
+
"""Raised at the point of use, in the task that actually needs the key."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _explain() -> str:
|
|
23
|
+
return (
|
|
24
|
+
f"{API_KEY_ENV} is not set, so System 1 cannot be reached.\n\n"
|
|
25
|
+
"Declare the secret on the TaskEnvironment that runs this task:\n\n"
|
|
26
|
+
" import flyte\n\n"
|
|
27
|
+
" env = flyte.TaskEnvironment(\n"
|
|
28
|
+
' "triage",\n'
|
|
29
|
+
f' secrets=[flyte.Secret(key="{API_KEY_ENV}", as_env_var="{API_KEY_ENV}")],\n'
|
|
30
|
+
" )\n\n"
|
|
31
|
+
"and create it once, if it does not exist yet:\n\n"
|
|
32
|
+
f" flyte create secret {API_KEY_ENV} --value <your key>\n\n"
|
|
33
|
+
"The check happens here, at the point of use, rather than at import: a task that merely "
|
|
34
|
+
"passes answers along does not need the key, and an import-time failure would take down "
|
|
35
|
+
"every task in the module."
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def client(*, api_key: Optional[str] = None, model: Optional[str] = None, **kwargs: Any):
|
|
40
|
+
"""An `AsyncTypeSafeClient`, or a message that says exactly what to do.
|
|
41
|
+
|
|
42
|
+
Without this, a missing key surfaces as a 401 from inside the vendor SDK, which
|
|
43
|
+
tells you nothing about Flyte secrets.
|
|
44
|
+
"""
|
|
45
|
+
from typesafe_sdk import AsyncTypeSafeClient
|
|
46
|
+
|
|
47
|
+
key = api_key or os.environ.get(API_KEY_ENV, "").strip()
|
|
48
|
+
if not key:
|
|
49
|
+
raise MissingAPIKey(_explain())
|
|
50
|
+
return AsyncTypeSafeClient(api_key=key, model=model, **kwargs)
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Recover an enum's own documentation, so the criteria do not have to be repeated.
|
|
2
|
+
|
|
3
|
+
An enum already says what its members mean -- in a class docstring, and in the
|
|
4
|
+
string literals underneath each member. The first is a real attribute; the second
|
|
5
|
+
is not. A member assignment followed by a bare string literal leaves nothing on the
|
|
6
|
+
member at runtime (`Severity.NONE.__doc__` returns the *class* docstring it
|
|
7
|
+
inherits), so the only way to read it is to parse the source, which is what pydantic
|
|
8
|
+
does for `use_attribute_docstrings` too.
|
|
9
|
+
|
|
10
|
+
That makes member docs a best-effort input: they work from a normal module on
|
|
11
|
+
disk and quietly return nothing when the source is not available (a REPL, `exec`,
|
|
12
|
+
some frozen or zipped deployments). Everything here degrades to the member name
|
|
13
|
+
rather than failing, because a missing description is a worse prompt, not a broken
|
|
14
|
+
program.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import ast
|
|
20
|
+
import enum
|
|
21
|
+
import inspect
|
|
22
|
+
import textwrap
|
|
23
|
+
from functools import lru_cache
|
|
24
|
+
from typing import Dict, Optional, Type
|
|
25
|
+
|
|
26
|
+
#: Python <= 3.11 writes this docstring onto an undocumented Enum class itself.
|
|
27
|
+
#: It is not something the author wrote, so it must not be mistaken for a question.
|
|
28
|
+
#: (3.12+ leaves __doc__ as None on the class and only inherits from Enum.)
|
|
29
|
+
_DEFAULT_ENUM_DOC = "An enumeration."
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@lru_cache(maxsize=None)
|
|
33
|
+
def class_doc(cls: Type) -> Optional[str]:
|
|
34
|
+
"""The enum's *own* class docstring, or None if it does not really have one.
|
|
35
|
+
|
|
36
|
+
Deliberately not `inspect.getdoc`: that inherits, and an undocumented enum
|
|
37
|
+
would then report CPython's own text -- "Create a collection of name/value
|
|
38
|
+
pairs." on 3.12+, or "Enum where members are also (and must be) ints" for an
|
|
39
|
+
IntEnum -- which would be sent to the model as the question. Reading the class
|
|
40
|
+
dict directly means only a docstring written on this enum counts.
|
|
41
|
+
"""
|
|
42
|
+
doc = cls.__dict__.get("__doc__")
|
|
43
|
+
if not isinstance(doc, str) or not doc.strip() or doc.strip() == _DEFAULT_ENUM_DOC:
|
|
44
|
+
return None
|
|
45
|
+
return " ".join(doc.split())
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@lru_cache(maxsize=None)
|
|
49
|
+
def member_docs(cls: Type[enum.Enum]) -> Dict[str, str]:
|
|
50
|
+
"""{member name: docstring} for members documented by a literal underneath them."""
|
|
51
|
+
try:
|
|
52
|
+
source = inspect.getsource(cls)
|
|
53
|
+
except (OSError, TypeError): # no source on disk: interactive, exec'd, frozen
|
|
54
|
+
return {}
|
|
55
|
+
try:
|
|
56
|
+
tree = ast.parse(textwrap.dedent(source)).body[0]
|
|
57
|
+
except (SyntaxError, IndexError):
|
|
58
|
+
return {}
|
|
59
|
+
if not isinstance(tree, ast.ClassDef):
|
|
60
|
+
return {}
|
|
61
|
+
|
|
62
|
+
docs: Dict[str, str] = {}
|
|
63
|
+
pending: Optional[str] = None
|
|
64
|
+
for node in tree.body:
|
|
65
|
+
if isinstance(node, (ast.Assign, ast.AnnAssign)):
|
|
66
|
+
target = node.targets[0] if isinstance(node, ast.Assign) else node.target
|
|
67
|
+
pending = target.id if isinstance(target, ast.Name) else None
|
|
68
|
+
elif (
|
|
69
|
+
pending
|
|
70
|
+
and isinstance(node, ast.Expr)
|
|
71
|
+
and isinstance(node.value, ast.Constant)
|
|
72
|
+
and isinstance(node.value.value, str)
|
|
73
|
+
):
|
|
74
|
+
docs[pending] = " ".join(node.value.value.split())
|
|
75
|
+
pending = None
|
|
76
|
+
else:
|
|
77
|
+
pending = None
|
|
78
|
+
return docs
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""The three answer types, as plain dataclasses.
|
|
2
|
+
|
|
3
|
+
TypeSafe's System One model ("Jev") answers typed questions instead of writing
|
|
4
|
+
text, and every answer arrives with the calibration that produced it. These
|
|
5
|
+
dataclasses are how that answer crosses a Flyte task boundary: Flyte's
|
|
6
|
+
`DataclassTransformer` carries them with no registration and no pydantic, and
|
|
7
|
+
nesting an `Enum` or `IntEnum` inside one keeps it a real member on the way
|
|
8
|
+
back out rather than degrading it to a string or an int.
|
|
9
|
+
|
|
10
|
+
Note the deliberate name collision with `typesafe_sdk.Choice` / `Score` /
|
|
11
|
+
`Noul`: those describe the *question* you ask, these hold the *answer* you get.
|
|
12
|
+
You write the ones in this module; the plugin builds the SDK's from your battery.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import enum
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from typing import Generic, Optional, TypeVar
|
|
20
|
+
|
|
21
|
+
C = TypeVar("C", bound=enum.Enum)
|
|
22
|
+
S = TypeVar("S", bound=enum.IntEnum)
|
|
23
|
+
|
|
24
|
+
#: Metadata keys on a battery field. They are the SDK's own words: a question
|
|
25
|
+
#: has `instructions` and `criteria`, and the shape of the criteria depends
|
|
26
|
+
#: on the question type, exactly as in `typesafe_sdk`.
|
|
27
|
+
QUESTION_KEY = "question"
|
|
28
|
+
CRITERIA_KEY = "criteria"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class Choice(Generic[C]):
|
|
33
|
+
"""One pick from a fixed vocabulary, carrying the calibration it came with."""
|
|
34
|
+
|
|
35
|
+
value: C
|
|
36
|
+
confidence: float = 0.0
|
|
37
|
+
probabilities: dict[str, float] = field(default_factory=dict)
|
|
38
|
+
|
|
39
|
+
def certain(self, threshold: float) -> bool:
|
|
40
|
+
"""Is this pick confident enough to act on without a human?"""
|
|
41
|
+
return self.confidence >= threshold
|
|
42
|
+
|
|
43
|
+
def runner_up(self) -> Optional[tuple[str, float]]:
|
|
44
|
+
"""The second-most-likely option, which is what you show a reviewer."""
|
|
45
|
+
ranked = sorted(self.probabilities.items(), key=lambda kv: kv[1], reverse=True)
|
|
46
|
+
return ranked[1] if len(ranked) > 1 else None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class Score(Generic[S]):
|
|
51
|
+
"""A position on a rubric.
|
|
52
|
+
|
|
53
|
+
Two representations, because both are useful: `value` is the rung that was
|
|
54
|
+
picked (an `IntEnum` member, so it compares and orders), and `position`
|
|
55
|
+
is the unrounded place on the scale that Jev actually returned. Branch on the
|
|
56
|
+
first, sort and threshold on the second.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
value: S
|
|
60
|
+
position: float = 0.0
|
|
61
|
+
confidence: float = 0.0
|
|
62
|
+
probabilities: dict[str, float] = field(default_factory=dict)
|
|
63
|
+
|
|
64
|
+
def at_least(self, rung: S) -> bool:
|
|
65
|
+
return self.value >= rung
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class Noul:
|
|
70
|
+
"""Truthfulness in 0..1.
|
|
71
|
+
|
|
72
|
+
Deliberately no `__bool__`: `if noul:` would make 0.02 and 0.98 alike,
|
|
73
|
+
and choosing the threshold is the part you want in code where it can be read,
|
|
74
|
+
reviewed and changed.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
value: float = 0.0
|
|
78
|
+
|
|
79
|
+
def at(self, threshold: float) -> bool:
|
|
80
|
+
return self.value >= threshold
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass
|
|
84
|
+
class CallInfo:
|
|
85
|
+
"""What one `system_one` call cost, for the report and for cost accounting."""
|
|
86
|
+
|
|
87
|
+
model: str = ""
|
|
88
|
+
questions: int = 0
|
|
89
|
+
input_tokens: int = 0
|
|
90
|
+
output_tokens: int = 0
|
|
91
|
+
latency_s: float = 0.0
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: flyteplugins-typesafe-ai
|
|
3
|
+
Version: 0.0.0a0
|
|
4
|
+
Summary: Run TypeSafe's System One model (Jev) inside durable Flyte tasks
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: typesafe-sdk>=0.5.7
|
|
8
|
+
Requires-Dist: flyte
|
|
9
|
+
|
|
10
|
+
# flyteplugins-typesafe-ai
|
|
11
|
+
|
|
12
|
+
Run [TypeSafe](https://docs.typesafe.ai/introduction)'s System One model ("Jev")
|
|
13
|
+
inside durable Flyte tasks.
|
|
14
|
+
|
|
15
|
+
Jev does not write text. It answers typed questions — in parallel, in isolation,
|
|
16
|
+
and with calibrated confidence attached to every answer. The documented property
|
|
17
|
+
that makes it worth building around is that *"adding questions barely changes the
|
|
18
|
+
response time"*, so the right move is to ask many small questions in one call and
|
|
19
|
+
compose the result in code you can read and change.
|
|
20
|
+
|
|
21
|
+
This plugin supplies the two things that takes on Flyte: a shape for the answers
|
|
22
|
+
that survives a task boundary, and a way to ask a whole battery at once.
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install flyteplugins-typesafe-ai
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Quickstart
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
import enum
|
|
32
|
+
from dataclasses import dataclass, field
|
|
33
|
+
|
|
34
|
+
import flyte
|
|
35
|
+
from flyteplugins.typesafe_ai import Choice, Noul, Score, ask
|
|
36
|
+
|
|
37
|
+
env = flyte.TaskEnvironment(
|
|
38
|
+
"triage",
|
|
39
|
+
secrets=[flyte.Secret(key="TYPESAFE_API_KEY", as_env_var="TYPESAFE_API_KEY")],
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Intent(enum.Enum):
|
|
44
|
+
"""What is this customer asking for?"""
|
|
45
|
+
|
|
46
|
+
REFUND = "refund"
|
|
47
|
+
"""they want money back"""
|
|
48
|
+
DELIVERY = "delivery status"
|
|
49
|
+
"""they are asking where their order is"""
|
|
50
|
+
OTHER = "something else"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class Severity(enum.IntEnum):
|
|
54
|
+
"""How badly are they blocked?"""
|
|
55
|
+
|
|
56
|
+
NONE = 0
|
|
57
|
+
"""no impact; a question or a comment"""
|
|
58
|
+
MINOR = 1
|
|
59
|
+
"""inconvenient, but they can carry on"""
|
|
60
|
+
SERIOUS = 2
|
|
61
|
+
"""they are blocked and a deadline is involved"""
|
|
62
|
+
BLOCKING = 3
|
|
63
|
+
"""they cannot use the product at all"""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class Triage:
|
|
68
|
+
# The enums document themselves: class docstring -> question, member docstrings -> criteria.
|
|
69
|
+
intent: Choice[Intent]
|
|
70
|
+
severity: Score[Severity]
|
|
71
|
+
# A Noul has no vocabulary to document itself with, so it carries both.
|
|
72
|
+
hostile: Noul = field(
|
|
73
|
+
metadata={
|
|
74
|
+
"question": "Is the customer hostile?",
|
|
75
|
+
"criteria": {"true": "insults or threats", "false": "civil"},
|
|
76
|
+
}
|
|
77
|
+
)
|
|
78
|
+
money_at_stake: Noul = field(metadata={"question": "Is a payment or refund involved?"})
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@env.task
|
|
82
|
+
async def handle(ticket: str) -> str:
|
|
83
|
+
t = await ask(Triage, {"ticket": ticket}) # one request, four answers
|
|
84
|
+
|
|
85
|
+
if t.hostile.at(0.8): # thresholds live in your code
|
|
86
|
+
return "escalate"
|
|
87
|
+
if not t.intent.certain(0.85):
|
|
88
|
+
return "review"
|
|
89
|
+
return f"auto: {t.intent.value.value}, severity {t.severity.value.name}"
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## The three answer types
|
|
93
|
+
|
|
94
|
+
| Type | Holds | Useful members |
|
|
95
|
+
| --- | --- | --- |
|
|
96
|
+
| `Choice[SomeEnum]` | the picked member, `confidence`, `probabilities` | `.certain(threshold)`, `.runner_up()` |
|
|
97
|
+
| `Score[SomeIntEnum]` | the picked rung, the unrounded `position`, `confidence` | `.at_least(rung)` |
|
|
98
|
+
| `Noul` | truthfulness in 0..1 | `.at(threshold)` |
|
|
99
|
+
|
|
100
|
+
`Choice` comes back as the **enum member**, not a string, and `Score` keeps both
|
|
101
|
+
representations on purpose: `value` is the rung you branch on, `position` is where
|
|
102
|
+
on the scale the answer actually landed, which is what you sort and threshold by.
|
|
103
|
+
|
|
104
|
+
`Noul` deliberately has no `__bool__`. `if noul:` would treat 0.02 and 0.98 alike,
|
|
105
|
+
and picking the threshold is the part that belongs in reviewable code.
|
|
106
|
+
|
|
107
|
+
These are plain dataclasses, so **pydantic is not required** and no bespoke type
|
|
108
|
+
transformer exists — they reuse Flyte's built-in `DataclassTransformer`. The
|
|
109
|
+
plugin registers them with the type engine through the standard
|
|
110
|
+
`flyte.plugins.types` entry point, so `flyte.init()` picks them up, and importing
|
|
111
|
+
the package registers them too.
|
|
112
|
+
|
|
113
|
+
## Declaring questions
|
|
114
|
+
|
|
115
|
+
The vocabulary documents itself. An enum's **class docstring** is the question and
|
|
116
|
+
its **member docstrings** are the criteria, so a documented enum needs nothing at
|
|
117
|
+
the call site:
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
@dataclass
|
|
121
|
+
class Triage:
|
|
122
|
+
intent: Choice[Intent] # question and criteria both come from Intent
|
|
123
|
+
severity: Score[Severity]
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Override either in ordinary `dataclasses.field` metadata, under two keys named
|
|
127
|
+
after the SDK's own arguments:
|
|
128
|
+
|
|
129
|
+
| key | meaning |
|
|
130
|
+
| --- | --- |
|
|
131
|
+
| `question` | the instructions for this question |
|
|
132
|
+
| `criteria` | the same shape `typesafe_sdk` takes for that question type |
|
|
133
|
+
|
|
134
|
+
```python
|
|
135
|
+
intent: Choice[Intent] = field(metadata={"question": "asked a different way"})
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
`criteria` follows the SDK exactly: a mapping keyed by enum **member name** for a
|
|
139
|
+
`Choice`, a **positional** sequence of rungs for a `Score` (so the `IntEnum` must
|
|
140
|
+
number its rungs `0..n-1` — a gap is rejected with an error that says so), and
|
|
141
|
+
`{"true": ..., "false": ...}` for a `Noul`.
|
|
142
|
+
|
|
143
|
+
`Noul` is the one that always needs you: it has no vocabulary to document itself
|
|
144
|
+
with, so a `Noul` without a question is an error naming the field.
|
|
145
|
+
|
|
146
|
+
Member docstrings are not stored on the object at runtime — `Severity.NONE.__doc__`
|
|
147
|
+
returns the *class* docstring it inherits — so they are read by parsing the source,
|
|
148
|
+
the same way pydantic implements `use_attribute_docstrings`. That makes them
|
|
149
|
+
best-effort: where the source is not available (a REPL, `exec`, some frozen
|
|
150
|
+
deployments) the criterion falls back to the member name rather than failing.
|
|
151
|
+
|
|
152
|
+
## Three ways to ask
|
|
153
|
+
|
|
154
|
+
`ask()` takes any of these and compiles them into a **single** `system_one` call:
|
|
155
|
+
|
|
156
|
+
```python
|
|
157
|
+
triage = await ask(Triage, state) # a battery dataclass -> Triage
|
|
158
|
+
intent = await ask(Choice[Intent], state) # one question -> Choice[Intent]
|
|
159
|
+
answers = await ask({"intent": Choice[Intent], # an ad-hoc battery -> dict
|
|
160
|
+
"hostile": Noul}, state)
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Outside a dataclass there is no field to hang metadata on, so `Annotated` carries
|
|
164
|
+
it instead — a mapping, or a bare string when all you have is the question:
|
|
165
|
+
|
|
166
|
+
```python
|
|
167
|
+
await ask(Annotated[Score[Harm], {"question": "How much harm would this do?"}], state)
|
|
168
|
+
await ask(Annotated[Noul, "Is this aimed at a specific person?"], state)
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Both forms work on a dataclass field too. If a field has metadata *and* an
|
|
172
|
+
`Annotated` annotation, the field metadata wins — it is the more specific place to
|
|
173
|
+
say it.
|
|
174
|
+
|
|
175
|
+
Prefer one call to several: three separate `ask()` calls are three round trips,
|
|
176
|
+
while a battery or a mapping asks everything at once, which is the property the
|
|
177
|
+
whole design rests on. Use `ask_with_info()` when you want the model name, question
|
|
178
|
+
count, token usage and latency back alongside the answers.
|
|
179
|
+
|
|
180
|
+
The name collision with `typesafe_sdk.Choice` / `Score` / `Noul` is deliberate and
|
|
181
|
+
one-directional: those describe the **question**, these hold the **answer**. You
|
|
182
|
+
write the ones in this package; the plugin builds the SDK's from your battery.
|
|
183
|
+
|
|
184
|
+
## The API key
|
|
185
|
+
|
|
186
|
+
The SDK reads the key from `TYPESAFE_API_KEY`, so mount your secret as that env
|
|
187
|
+
var. There is no helper for this — it is a plain `flyte.Secret`:
|
|
188
|
+
|
|
189
|
+
```python
|
|
190
|
+
env = flyte.TaskEnvironment(
|
|
191
|
+
"triage",
|
|
192
|
+
secrets=[flyte.Secret(key="TYPESAFE_API_KEY", as_env_var="TYPESAFE_API_KEY")],
|
|
193
|
+
)
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
`flyte.Secret` derives `as_env_var` from the key by upper-casing it and swapping
|
|
197
|
+
`-` for `_`, so a secret named `TYPESAFE_API_KEY` mounts correctly from
|
|
198
|
+
`flyte.Secret(key="TYPESAFE_API_KEY")` alone. Spelling `as_env_var` out is worth
|
|
199
|
+
the extra words: it is the string you will grep for when a task cannot find the key.
|
|
200
|
+
|
|
201
|
+
If your secret is stored under a different name, point the key at it and keep the
|
|
202
|
+
mount:
|
|
203
|
+
|
|
204
|
+
```python
|
|
205
|
+
flyte.Secret(key="my-org-typesafe-key", as_env_var="TYPESAFE_API_KEY")
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
Create the secret once:
|
|
209
|
+
|
|
210
|
+
```bash
|
|
211
|
+
flyte create secret TYPESAFE_API_KEY --value <your key>
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
If the key is missing, the failure happens **at the point of use** — in the task
|
|
215
|
+
that actually calls System One — with a message naming the declaration and the CLI
|
|
216
|
+
command. It is deliberately not an import-time check: a module's tasks are imported
|
|
217
|
+
together on the dataplane, so an import-time raise would take down tasks that never
|
|
218
|
+
touch System One, and a task that merely passes answers along needs no key at all.
|
|
219
|
+
|
|
220
|
+
## Examples
|
|
221
|
+
|
|
222
|
+
- [`examples/triage.py`](examples/triage.py) — fourteen questions in one call, then
|
|
223
|
+
confidence-gated routing in ordinary Python
|
|
224
|
+
- [`examples/fanout.py`](examples/fanout.py) — a backlog of tickets, one durable
|
|
225
|
+
Flyte task each, one System One call inside each
|
|
226
|
+
- [`examples/single.py`](examples/single.py) — `Choice`, `Score` and `Noul` used on
|
|
227
|
+
their own, without a battery dataclass
|
|
228
|
+
|
|
229
|
+
Both run against a cluster:
|
|
230
|
+
|
|
231
|
+
```bash
|
|
232
|
+
flyte run --root-dir plugins/typesafe-ai/examples plugins/typesafe-ai/examples/triage.py handle
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
While this package is unpublished, set `TYPESAFE_LOCAL_WHEELS=1` and build the
|
|
236
|
+
wheels first with `make dist && make dist-plugins`.
|
|
237
|
+
|
|
238
|
+
## Answers as task inputs and outputs
|
|
239
|
+
|
|
240
|
+
`Choice`, `Score` and `Noul` are plain dataclasses, so Flyte carries them with
|
|
241
|
+
nothing registered — including on their own, not just inside a battery:
|
|
242
|
+
|
|
243
|
+
```python
|
|
244
|
+
@env.task
|
|
245
|
+
async def classify(message: str) -> Choice[Action]:
|
|
246
|
+
return await ask(Choice[Action], {"message": message})
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
They also get the dict coercion every dataclass input gets, so a caller may pass
|
|
250
|
+
`{"value": "refund", "confidence": 0.91}` where a `Choice[Intent]` is expected and
|
|
251
|
+
omitted fields fall back to their defaults. Note that an enum nested in a dataclass
|
|
252
|
+
is spelled by its **value** (`"refund"`), not its name — that is mashumaro's
|
|
253
|
+
convention for dataclass fields, and it differs from the name-based spelling Flyte
|
|
254
|
+
uses for a bare enum at the top level.
|
|
255
|
+
|
|
256
|
+
Inference is never implicit. A dict arriving for a `Choice[Intent]` is the
|
|
257
|
+
*serialized answer*, never a state to go ask about — the two are indistinguishable
|
|
258
|
+
by shape, and replay depends on the serialized reading winning. If you want the
|
|
259
|
+
interface to say "System 1 produces this", make it a task: you get durability,
|
|
260
|
+
caching and retries, and the call stays visible in the run graph.
|
|
261
|
+
|
|
262
|
+
## A note on IntEnum
|
|
263
|
+
|
|
264
|
+
`Score` takes an `IntEnum` because a rubric is ordered. Flyte's enum transformer
|
|
265
|
+
used to accept string-valued enums only; it now supports `IntEnum` as well,
|
|
266
|
+
serialized by member name like every other enum, so `severity: Severity` also works
|
|
267
|
+
as a bare task input or output. `Flag`/`IntFlag` remain unsupported, with a message
|
|
268
|
+
explaining why: a composite member like `READ|WRITE` has a name but cannot be looked
|
|
269
|
+
up by it, so it cannot come back.
|
|
270
|
+
|
|
271
|
+
A second core change makes `Choice[Intent]` work as a task type at all: a
|
|
272
|
+
parameterized dataclass is a generic *alias*, which `dataclasses.is_dataclass()`
|
|
273
|
+
rejects, so it used to fall through the type engine to pickle. The alias now
|
|
274
|
+
resolves to its origin for structural checks while the alias itself is kept for
|
|
275
|
+
decoding, which is what binds the type variable.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
flyteplugins/typesafe_ai/__init__.py,sha256=Y_bjsLJCsYV5tTa3FG1EZCb02_3CbJVTQbrHXqeVeRQ,3121
|
|
2
|
+
flyteplugins/typesafe_ai/_ask.py,sha256=IY_h9JsRuk54AqrEv3TJ0qS8Pkwwx4hybph3cCJM09o,14171
|
|
3
|
+
flyteplugins/typesafe_ai/_client.py,sha256=gG3ZD31KFLc5vn8jzuGRBGv0ejR2j2Do72uGGsqgv7U,2002
|
|
4
|
+
flyteplugins/typesafe_ai/_docs.py,sha256=upSH87D3TYSGQkpT7EqvbfliJeZ_ejZNY8ed_M1sYUk,3197
|
|
5
|
+
flyteplugins/typesafe_ai/_types.py,sha256=_gQ9HX5JK2EI5rwEhdYCa8j7vbJyjL5TEjlseEnKvJc,3032
|
|
6
|
+
flyteplugins_typesafe_ai-0.0.0a0.dist-info/METADATA,sha256=DXDgECn0DTfrEmszYJwymVBs4LRKt5BFCFF2A7OvhsM,10877
|
|
7
|
+
flyteplugins_typesafe_ai-0.0.0a0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
8
|
+
flyteplugins_typesafe_ai-0.0.0a0.dist-info/entry_points.txt,sha256=vJ0BUqt9Y4HiN6Nbwr7CQtFp0gSJPGoKsS1SCJSqBWY,88
|
|
9
|
+
flyteplugins_typesafe_ai-0.0.0a0.dist-info/top_level.txt,sha256=cgd779rPu9EsvdtuYgUxNHHgElaQvPn74KhB5XSeMBE,13
|
|
10
|
+
flyteplugins_typesafe_ai-0.0.0a0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
flyteplugins
|