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.
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [flyte.plugins.types]
2
+ typesafe_ai = flyteplugins.typesafe_ai:register_typesafe_ai_types
@@ -0,0 +1 @@
1
+ flyteplugins