dev-double 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.
- dev_double/__init__.py +3 -0
- dev_double/apps/__init__.py +1 -0
- dev_double/apps/cli/__init__.py +5 -0
- dev_double/apps/cli/main.py +122 -0
- dev_double/apps/client/__init__.py +5 -0
- dev_double/apps/client/http_client.py +102 -0
- dev_double/apps/composition.py +71 -0
- dev_double/apps/config.py +36 -0
- dev_double/apps/server/__init__.py +5 -0
- dev_double/apps/server/app.py +122 -0
- dev_double/apps/server/recorder.py +62 -0
- dev_double/apps/server/transport/__init__.py +39 -0
- dev_double/apps/server/transport/choice_use_cases.py +121 -0
- dev_double/apps/server/transport/common.py +37 -0
- dev_double/apps/server/transport/decide.py +131 -0
- dev_double/apps/server/transport/extract.py +93 -0
- dev_double/apps/server/transport/guard_judge.py +97 -0
- dev_double/apps/server/transport/rerank.py +55 -0
- dev_double/client.py +5 -0
- dev_double/core/__init__.py +1 -0
- dev_double/core/decision/__init__.py +62 -0
- dev_double/core/decision/answer_shaping.py +48 -0
- dev_double/core/decision/confidence.py +19 -0
- dev_double/core/decision/decider_basic_impl.py +65 -0
- dev_double/core/decision/decision_service_basic_impl.py +141 -0
- dev_double/core/decision/defaults.py +40 -0
- dev_double/core/decision/errors.py +7 -0
- dev_double/core/decision/extract_questions.py +40 -0
- dev_double/core/decision/field_extraction.py +53 -0
- dev_double/core/decision/i_clock.py +11 -0
- dev_double/core/decision/i_decider.py +36 -0
- dev_double/core/decision/i_decision_service.py +40 -0
- dev_double/core/decision/i_engine.py +27 -0
- dev_double/core/decision/i_extractor.py +19 -0
- dev_double/core/decision/i_generator.py +19 -0
- dev_double/core/decision/i_id_provider.py +9 -0
- dev_double/core/decision/i_record_reader.py +25 -0
- dev_double/core/decision/i_reranker.py +18 -0
- dev_double/core/decision/label_scoring.py +89 -0
- dev_double/core/decision/prompts.py +130 -0
- dev_double/core/decision/record_parsing.py +153 -0
- dev_double/core/decision/t_answer.py +32 -0
- dev_double/core/decision/t_classify.py +42 -0
- dev_double/core/decision/t_decide.py +26 -0
- dev_double/core/decision/t_extract.py +89 -0
- dev_double/core/decision/t_gate.py +36 -0
- dev_double/core/decision/t_generate.py +37 -0
- dev_double/core/decision/t_guard.py +38 -0
- dev_double/core/decision/t_input.py +8 -0
- dev_double/core/decision/t_judge.py +35 -0
- dev_double/core/decision/t_label_query.py +36 -0
- dev_double/core/decision/t_meta.py +17 -0
- dev_double/core/decision/t_question.py +70 -0
- dev_double/core/decision/t_rerank.py +36 -0
- dev_double/core/decision/t_route.py +28 -0
- dev_double/core/decision/t_usage.py +15 -0
- dev_double/core/decision/tracker.py +27 -0
- dev_double/core/decision/use_case_questions.py +80 -0
- dev_double/core/decision/value_parsing.py +109 -0
- dev_double/providers/__init__.py +1 -0
- dev_double/providers/mock/__init__.py +1 -0
- dev_double/providers/mock/decision/__init__.py +5 -0
- dev_double/providers/mock/decision/clock_mock_impl.py +19 -0
- dev_double/providers/mock/decision/engine_mock_impl.py +78 -0
- dev_double/providers/mock/decision/id_provider_mock_impl.py +15 -0
- dev_double/providers/needle/__init__.py +1 -0
- dev_double/providers/needle/decision/__init__.py +4 -0
- dev_double/providers/needle/decision/decider_needle_impl.py +117 -0
- dev_double/providers/needle/decision/record_tool.py +75 -0
- dev_double/providers/openai/__init__.py +1 -0
- dev_double/providers/openai/decision/__init__.py +3 -0
- dev_double/providers/openai/decision/engine_openai_impl.py +154 -0
- dev_double/providers/std/__init__.py +1 -0
- dev_double/providers/std/decision/__init__.py +4 -0
- dev_double/providers/std/decision/clock_std_impl.py +12 -0
- dev_double/providers/std/decision/id_provider_std_impl.py +12 -0
- dev_double/providers/systemone/__init__.py +1 -0
- dev_double/providers/systemone/decision/__init__.py +3 -0
- dev_double/providers/systemone/decision/decider_system_one_impl.py +100 -0
- dev_double-0.1.0.dist-info/METADATA +354 -0
- dev_double-0.1.0.dist-info/RECORD +84 -0
- dev_double-0.1.0.dist-info/WHEEL +4 -0
- dev_double-0.1.0.dist-info/entry_points.txt +2 -0
- dev_double-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Turns a question into a single-label prompt.
|
|
2
|
+
|
|
3
|
+
Every prompt ends by asking for exactly one label (an option name, a digit, or
|
|
4
|
+
yes/no), so the answer sits in the first few tokens, whose probabilities we can read.
|
|
5
|
+
|
|
6
|
+
Choices are answered by option name, not by letter: small models over-pick
|
|
7
|
+
"B" when options are lettered, which skews the probabilities.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
|
|
14
|
+
from .t_extract import TExtractField
|
|
15
|
+
from .t_generate import TGenerateQuery
|
|
16
|
+
from .t_input import TInputValue
|
|
17
|
+
from .t_label_query import TLabelQuery
|
|
18
|
+
from .t_question import TBinaryQuestion, TChoiceQuestion, TQuestion, TScaleQuestion
|
|
19
|
+
|
|
20
|
+
SYSTEM = (
|
|
21
|
+
"You are a precise decision function. Read the input, then answer the question "
|
|
22
|
+
"with exactly one label from the allowed labels. Output only the label: no "
|
|
23
|
+
"explanation, no punctuation, no other text."
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
# Extraction: all free-text fields of a request in one JSON object. Wording
|
|
27
|
+
# chosen by A/B test against a per-field prompt (which small models answered
|
|
28
|
+
# with NONE for values plainly present, or echoed the field description).
|
|
29
|
+
RECORD_SYSTEM = (
|
|
30
|
+
"You extract data from text into JSON. Output one JSON object with exactly the requested "
|
|
31
|
+
"keys. Copy values from the text, converting them to the requested format. Use null only "
|
|
32
|
+
"when the text gives no information for a key. Output only the JSON."
|
|
33
|
+
)
|
|
34
|
+
RECORD_MAX_TOKENS = 512
|
|
35
|
+
|
|
36
|
+
def render_input(value: TInputValue) -> str:
|
|
37
|
+
if isinstance(value, str):
|
|
38
|
+
return value
|
|
39
|
+
return json.dumps(value, ensure_ascii=False, indent=2)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _frame(question: str, input_text: str, body: str) -> str:
|
|
43
|
+
# Asking the question before and after the input measurably helps small
|
|
44
|
+
# models: they read the input knowing what to look for.
|
|
45
|
+
return f"Question: {question}\n\n<input>\n{input_text}\n</input>\n\n{body}"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def binary(input_text: str, q: TBinaryQuestion) -> TLabelQuery:
|
|
49
|
+
lines = [f"Question: {q.question}"]
|
|
50
|
+
if q.yes:
|
|
51
|
+
lines.append(f'Answer "yes" if: {q.yes}')
|
|
52
|
+
if q.no:
|
|
53
|
+
lines.append(f'Answer "no" if: {q.no}')
|
|
54
|
+
lines.append("Reply with exactly one word: yes or no.")
|
|
55
|
+
return TLabelQuery(
|
|
56
|
+
system=SYSTEM,
|
|
57
|
+
user=_frame(q.question, input_text, "\n".join(lines)),
|
|
58
|
+
labels=["yes", "no"],
|
|
59
|
+
input_text=input_text,
|
|
60
|
+
question=q.question,
|
|
61
|
+
descriptions=[f"{q.question} {q.yes or ''}", q.no or ""],
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def choice(input_text: str, q: TChoiceQuestion) -> TLabelQuery:
|
|
66
|
+
keys = list(q.options)
|
|
67
|
+
option_lines = [f"- {key}: {q.options[key]}" for key in keys]
|
|
68
|
+
body = (
|
|
69
|
+
f"Question: {q.question}\n\nOptions:\n"
|
|
70
|
+
+ "\n".join(option_lines)
|
|
71
|
+
+ f"\n\nReply with exactly one option name: {', '.join(keys)}."
|
|
72
|
+
)
|
|
73
|
+
return TLabelQuery(
|
|
74
|
+
system=SYSTEM,
|
|
75
|
+
user=_frame(q.question, input_text, body),
|
|
76
|
+
labels=keys,
|
|
77
|
+
input_text=input_text,
|
|
78
|
+
question=q.question,
|
|
79
|
+
descriptions=[f"{key} {q.options[key]}" for key in keys],
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def scale(input_text: str, q: TScaleQuestion) -> TLabelQuery:
|
|
84
|
+
digits = [str(i) for i in range(len(q.levels))]
|
|
85
|
+
level_lines = [f"{d}: {text}" for d, text in zip(digits, q.levels)]
|
|
86
|
+
body = (
|
|
87
|
+
f"Question: {q.question}\n\nLevels (lowest first):\n"
|
|
88
|
+
+ "\n".join(level_lines)
|
|
89
|
+
+ f"\n\nReply with exactly one digit: {', '.join(digits)}."
|
|
90
|
+
)
|
|
91
|
+
return TLabelQuery(
|
|
92
|
+
system=SYSTEM,
|
|
93
|
+
user=_frame(q.question, input_text, body),
|
|
94
|
+
labels=digits,
|
|
95
|
+
input_text=input_text,
|
|
96
|
+
question=q.question,
|
|
97
|
+
descriptions=list(q.levels),
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def query_for(input_text: str, q: TQuestion) -> TLabelQuery:
|
|
102
|
+
"""Dispatch on the question type."""
|
|
103
|
+
if isinstance(q, TBinaryQuestion):
|
|
104
|
+
return binary(input_text, q)
|
|
105
|
+
if isinstance(q, TChoiceQuestion):
|
|
106
|
+
return choice(input_text, q)
|
|
107
|
+
return scale(input_text, q)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def record_max_tokens(n_fields: int) -> int:
|
|
111
|
+
return min(RECORD_MAX_TOKENS, 48 + 40 * n_fields)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def record_key_line(name: str, field: TExtractField) -> str:
|
|
115
|
+
return f'- "{name}" ({field.type}): {field.description or name.replace("_", " ")}'
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def extract_record(input_text: str, fields: dict[str, TExtractField]) -> TGenerateQuery:
|
|
119
|
+
"""Free-text fields (string, number, integer) as one JSON object, keys listed
|
|
120
|
+
before and after the text."""
|
|
121
|
+
keys = "\n".join(record_key_line(name, f) for name, f in fields.items())
|
|
122
|
+
user = f"Keys:\n{keys}\n\n<text>\n{input_text}\n</text>\n\nFill in the keys from the text above:\n{keys}\n\nJSON:"
|
|
123
|
+
return TGenerateQuery(
|
|
124
|
+
system=RECORD_SYSTEM,
|
|
125
|
+
user=user,
|
|
126
|
+
max_tokens=record_max_tokens(len(fields)),
|
|
127
|
+
json=True,
|
|
128
|
+
input_text=input_text,
|
|
129
|
+
fields=tuple(fields),
|
|
130
|
+
)
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Pure helpers that turn a model's JSON record into typed field values, with a
|
|
2
|
+
per-field confidence read from the tokens that spell each value.
|
|
3
|
+
|
|
4
|
+
The model may wrap the object in ``` fences or prose; we take the outermost
|
|
5
|
+
{...}. Each value's span in the output is located by a small depth-aware
|
|
6
|
+
scan, and its confidence is exp(mean logprob) of the tokens overlapping it.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import math
|
|
13
|
+
from typing import Any, Optional, Sequence
|
|
14
|
+
|
|
15
|
+
from .confidence import DIGITS
|
|
16
|
+
from .t_extract import EMPTY, TExtractField, TFieldValue
|
|
17
|
+
from .tracker import Tracker
|
|
18
|
+
from .value_parsing import coerce_json_value
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def parse_object(text: str) -> Optional[dict[str, Any]]:
|
|
22
|
+
"""The outermost {...} in the text as a dict, or None if it isn't a JSON object."""
|
|
23
|
+
start, end = text.find("{"), text.rfind("}")
|
|
24
|
+
if start < 0 or end < start:
|
|
25
|
+
return None
|
|
26
|
+
try:
|
|
27
|
+
obj = json.loads(text[start : end + 1])
|
|
28
|
+
except ValueError:
|
|
29
|
+
return None
|
|
30
|
+
return obj if isinstance(obj, dict) else None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _string_end(text: str, i: int) -> int:
|
|
34
|
+
"""Index just past the JSON string starting at text[i] == '"', or -1."""
|
|
35
|
+
j = i + 1
|
|
36
|
+
while j < len(text):
|
|
37
|
+
if text[j] == "\\":
|
|
38
|
+
j += 2
|
|
39
|
+
continue
|
|
40
|
+
if text[j] == '"':
|
|
41
|
+
return j + 1
|
|
42
|
+
j += 1
|
|
43
|
+
return -1
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _value_end(text: str, j: int) -> int:
|
|
47
|
+
"""Index of the ',' or '}' that ends the value starting at j (depth 0, outside strings)."""
|
|
48
|
+
depth = 0
|
|
49
|
+
while j < len(text):
|
|
50
|
+
ch = text[j]
|
|
51
|
+
if ch == '"':
|
|
52
|
+
end = _string_end(text, j)
|
|
53
|
+
if end < 0:
|
|
54
|
+
return len(text)
|
|
55
|
+
j = end
|
|
56
|
+
continue
|
|
57
|
+
if ch in "[{":
|
|
58
|
+
depth += 1
|
|
59
|
+
elif ch in "]}":
|
|
60
|
+
if depth == 0:
|
|
61
|
+
return j
|
|
62
|
+
depth -= 1
|
|
63
|
+
elif ch == "," and depth == 0:
|
|
64
|
+
return j
|
|
65
|
+
j += 1
|
|
66
|
+
return j
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def value_spans(text: str) -> dict[str, tuple[int, int]]:
|
|
70
|
+
"""Key -> (start, end) of its raw value (e.g. '"Acme"', '12.5', 'null') in the
|
|
71
|
+
first top-level object of the text. Stops quietly at malformed JSON."""
|
|
72
|
+
i = text.find("{")
|
|
73
|
+
if i < 0:
|
|
74
|
+
return {}
|
|
75
|
+
spans: dict[str, tuple[int, int]] = {}
|
|
76
|
+
i += 1
|
|
77
|
+
while i < len(text):
|
|
78
|
+
if text[i].isspace() or text[i] == ",":
|
|
79
|
+
i += 1
|
|
80
|
+
continue
|
|
81
|
+
if text[i] != '"':
|
|
82
|
+
break # '}' or malformed
|
|
83
|
+
key_end = _string_end(text, i)
|
|
84
|
+
if key_end < 0:
|
|
85
|
+
break
|
|
86
|
+
try:
|
|
87
|
+
key = json.loads(text[i:key_end])
|
|
88
|
+
except ValueError:
|
|
89
|
+
break
|
|
90
|
+
j = key_end
|
|
91
|
+
while j < len(text) and text[j].isspace():
|
|
92
|
+
j += 1
|
|
93
|
+
if j >= len(text) or text[j] != ":":
|
|
94
|
+
break
|
|
95
|
+
j += 1
|
|
96
|
+
while j < len(text) and text[j].isspace():
|
|
97
|
+
j += 1
|
|
98
|
+
end = _value_end(text, j)
|
|
99
|
+
stop = end
|
|
100
|
+
while stop > j and text[stop - 1].isspace():
|
|
101
|
+
stop -= 1
|
|
102
|
+
spans[key] = (j, stop)
|
|
103
|
+
i = end
|
|
104
|
+
return spans
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def span_confidence(tokens: Sequence[tuple[str, float]], start: int, end: int) -> Optional[float]:
|
|
108
|
+
"""exp(mean logprob) of the tokens overlapping text[start:end], or None if none do."""
|
|
109
|
+
logprobs, offset = [], 0
|
|
110
|
+
for token, logprob in tokens:
|
|
111
|
+
if offset < end and offset + len(token) > start:
|
|
112
|
+
logprobs.append(logprob)
|
|
113
|
+
offset += len(token)
|
|
114
|
+
if not logprobs:
|
|
115
|
+
return None
|
|
116
|
+
return min(1.0, math.exp(sum(logprobs) / len(logprobs)))
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def parse_record(
|
|
120
|
+
text: str,
|
|
121
|
+
tokens: Sequence[tuple[str, float]],
|
|
122
|
+
fields: dict[str, TExtractField],
|
|
123
|
+
fallback_confidence: float,
|
|
124
|
+
tracker: Tracker,
|
|
125
|
+
) -> dict[str, TFieldValue]:
|
|
126
|
+
"""Field name -> value for the text fields in a generated JSON record.
|
|
127
|
+
|
|
128
|
+
Missing keys and nulls give None. A value's confidence comes from its own
|
|
129
|
+
tokens; ``fallback_confidence`` (the whole generation's) is used when there
|
|
130
|
+
are no tokens or the value's span can't be found in them.
|
|
131
|
+
"""
|
|
132
|
+
obj = parse_object(text)
|
|
133
|
+
if obj is None:
|
|
134
|
+
tracker.warn(f"the model did not return a JSON object (got {text[:40]!r}); text fields left empty")
|
|
135
|
+
return {name: EMPTY for name in fields}
|
|
136
|
+
spans = value_spans("".join(token for token, _ in tokens)) if tokens else {}
|
|
137
|
+
out: dict[str, TFieldValue] = {}
|
|
138
|
+
for name, field in fields.items():
|
|
139
|
+
conf = None
|
|
140
|
+
if name in spans:
|
|
141
|
+
conf = span_confidence(tokens, *spans[name])
|
|
142
|
+
conf = round(fallback_confidence if conf is None else conf, DIGITS)
|
|
143
|
+
raw = obj.get(name)
|
|
144
|
+
if raw is None:
|
|
145
|
+
out[name] = TFieldValue(None, conf)
|
|
146
|
+
continue
|
|
147
|
+
value, problem = coerce_json_value(raw, field.type)
|
|
148
|
+
if problem is not None:
|
|
149
|
+
tracker.warn(problem, name)
|
|
150
|
+
out[name] = EMPTY
|
|
151
|
+
continue
|
|
152
|
+
out[name] = TFieldValue(value, conf)
|
|
153
|
+
return out
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Answers to the three question types."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Union
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class TBinaryAnswer:
|
|
11
|
+
value: bool
|
|
12
|
+
probability: float # probability that the answer is yes
|
|
13
|
+
confidence: float
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class TChoiceAnswer:
|
|
18
|
+
value: str
|
|
19
|
+
probabilities: dict[str, float]
|
|
20
|
+
confidence: float
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class TScaleAnswer:
|
|
25
|
+
value: float # probability-weighted level, from 0 to len(levels) - 1
|
|
26
|
+
level: int # the single most likely level
|
|
27
|
+
probabilities: dict[str, float]
|
|
28
|
+
legend: dict[str, str]
|
|
29
|
+
confidence: float
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
TAnswer = Union[TBinaryAnswer, TChoiceAnswer, TScaleAnswer]
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Labeling one item or a batch."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
from .defaults import DEFAULT_CLASSIFY_QUESTION
|
|
9
|
+
from .t_input import TInputValue
|
|
10
|
+
from .t_meta import TMeta
|
|
11
|
+
from .t_question import check_options
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class TClassifyRequest:
|
|
16
|
+
labels: dict[str, str]
|
|
17
|
+
input: Optional[TInputValue] = None # one item...
|
|
18
|
+
inputs: Optional[list[TInputValue]] = None # ...or many, labeled in parallel
|
|
19
|
+
question: str = DEFAULT_CLASSIFY_QUESTION
|
|
20
|
+
|
|
21
|
+
def __post_init__(self) -> None:
|
|
22
|
+
check_options(self.labels, "labels")
|
|
23
|
+
if (self.input is None) == (self.inputs is None):
|
|
24
|
+
raise ValueError("Provide exactly one of `input` or `inputs`.")
|
|
25
|
+
if self.inputs is not None and not self.inputs:
|
|
26
|
+
raise ValueError("`inputs` must not be empty.")
|
|
27
|
+
|
|
28
|
+
def items(self) -> list[TInputValue]:
|
|
29
|
+
return self.inputs if self.inputs is not None else [self.input] # type: ignore[list-item]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class TClassification:
|
|
34
|
+
label: str
|
|
35
|
+
probabilities: dict[str, float]
|
|
36
|
+
confidence: float
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class TClassifyResponse:
|
|
41
|
+
results: list[TClassification]
|
|
42
|
+
meta: TMeta
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Generic decision: several typed questions about one input."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from .t_answer import TAnswer
|
|
8
|
+
from .t_input import TInputValue
|
|
9
|
+
from .t_meta import TMeta
|
|
10
|
+
from .t_question import TQuestion
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class TDecideRequest:
|
|
15
|
+
input: TInputValue
|
|
16
|
+
questions: dict[str, TQuestion]
|
|
17
|
+
|
|
18
|
+
def __post_init__(self) -> None:
|
|
19
|
+
if not self.questions:
|
|
20
|
+
raise ValueError("`questions` must not be empty.")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class TDecideResponse:
|
|
25
|
+
answers: dict[str, TAnswer]
|
|
26
|
+
meta: TMeta
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Structured extraction: typed fields pulled out of one input.
|
|
2
|
+
|
|
3
|
+
Enum and boolean fields are answered as choice and binary questions (real
|
|
4
|
+
probabilities); string, number, and integer fields are generated as text.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import Optional, Union
|
|
11
|
+
|
|
12
|
+
from .t_input import TInputValue
|
|
13
|
+
from .t_meta import TMeta
|
|
14
|
+
from .t_question import check_options
|
|
15
|
+
|
|
16
|
+
FIELD_TYPES = ("string", "number", "integer", "boolean", "enum")
|
|
17
|
+
TEXT_TYPES = ("string", "number", "integer") # generated as text, then parsed
|
|
18
|
+
MAX_FIELDS = 30
|
|
19
|
+
|
|
20
|
+
# An extracted value; None when the input doesn't contain the field.
|
|
21
|
+
TValue = Optional[Union[str, float, int, bool]]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _check_probability(p: Optional[float], what: str) -> None:
|
|
25
|
+
if p is not None and not 0 <= p <= 1:
|
|
26
|
+
raise ValueError(f"`{what}` must be between 0 and 1, got {p}.")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class TExtractField:
|
|
31
|
+
type: str # one of FIELD_TYPES
|
|
32
|
+
description: Optional[str] = None
|
|
33
|
+
# enum only: option -> description, or a list of option names (each its own description).
|
|
34
|
+
options: Optional[Union[dict[str, str], list[str]]] = None
|
|
35
|
+
|
|
36
|
+
def __post_init__(self) -> None:
|
|
37
|
+
if self.type not in FIELD_TYPES:
|
|
38
|
+
raise ValueError(f"Field `type` must be one of {', '.join(FIELD_TYPES)}; got {self.type!r}.")
|
|
39
|
+
if self.type != "enum":
|
|
40
|
+
if self.options is not None:
|
|
41
|
+
raise ValueError(f"Only enum fields take `options`, not {self.type} fields.")
|
|
42
|
+
return
|
|
43
|
+
if self.options is None:
|
|
44
|
+
raise ValueError("Enum fields need `options`.")
|
|
45
|
+
options = self.options
|
|
46
|
+
if isinstance(options, list):
|
|
47
|
+
if len(set(options)) != len(options):
|
|
48
|
+
raise ValueError("Duplicate enum options.")
|
|
49
|
+
options = {name: name for name in options}
|
|
50
|
+
check_options(options)
|
|
51
|
+
object.__setattr__(self, "options", dict(options))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class TExtractRequest:
|
|
56
|
+
input: TInputValue
|
|
57
|
+
fields: dict[str, TExtractField] # field name -> spec; answers keep this order
|
|
58
|
+
|
|
59
|
+
def __post_init__(self) -> None:
|
|
60
|
+
if not 1 <= len(self.fields) <= MAX_FIELDS:
|
|
61
|
+
raise ValueError(f"`fields` must have 1 to {MAX_FIELDS} entries, got {len(self.fields)}.")
|
|
62
|
+
for name in self.fields:
|
|
63
|
+
if not name.strip():
|
|
64
|
+
raise ValueError("Field names must not be empty.")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True)
|
|
68
|
+
class TFieldValue:
|
|
69
|
+
value: TValue
|
|
70
|
+
confidence: float
|
|
71
|
+
probabilities: Optional[dict[str, float]] = None # enum fields only
|
|
72
|
+
probability: Optional[float] = None # boolean fields only: probability of true
|
|
73
|
+
|
|
74
|
+
def __post_init__(self) -> None:
|
|
75
|
+
_check_probability(self.confidence, "confidence")
|
|
76
|
+
_check_probability(self.probability, "probability")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
EMPTY = TFieldValue(None, 0.0)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True)
|
|
83
|
+
class TExtractResponse:
|
|
84
|
+
fields: dict[str, TFieldValue]
|
|
85
|
+
meta: TMeta
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def values(self) -> dict[str, TValue]:
|
|
89
|
+
return {name: f.value for name, f in self.fields.items()}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Tool-call gating: allow, ask a human, or deny."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any, Optional, Union
|
|
7
|
+
|
|
8
|
+
from .t_input import TInputValue
|
|
9
|
+
from .t_meta import TMeta
|
|
10
|
+
from .t_question import check_options
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class TToolCall:
|
|
15
|
+
name: str
|
|
16
|
+
arguments: Union[dict[str, Any], str] = field(default_factory=dict)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class TGateRequest:
|
|
21
|
+
tool_call: TToolCall
|
|
22
|
+
context: Optional[TInputValue] = None # what the user asked for, or the recent conversation
|
|
23
|
+
policy: Optional[str] = None # rules for tool use, in plain language
|
|
24
|
+
outcomes: Optional[dict[str, str]] = None # outcome -> when; None = DEFAULT_OUTCOMES
|
|
25
|
+
|
|
26
|
+
def __post_init__(self) -> None:
|
|
27
|
+
if self.outcomes is not None:
|
|
28
|
+
check_options(self.outcomes, "outcomes")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class TGateResponse:
|
|
33
|
+
decision: str
|
|
34
|
+
probabilities: dict[str, float]
|
|
35
|
+
confidence: float
|
|
36
|
+
meta: TMeta
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""The generator contract's data: a rendered prompt in, a short text and how
|
|
2
|
+
sure the model was of it out."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
|
|
8
|
+
from .t_usage import TUsage
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class TGenerateQuery:
|
|
13
|
+
system: str
|
|
14
|
+
user: str
|
|
15
|
+
max_tokens: int
|
|
16
|
+
json: bool = False # ask for one JSON object (e.g. response_format json_object)
|
|
17
|
+
# Semantic parts, for engines that don't use the rendered prompt (e.g. mock).
|
|
18
|
+
input_text: str = ""
|
|
19
|
+
fields: tuple[str, ...] = () # the field names being extracted
|
|
20
|
+
|
|
21
|
+
def __post_init__(self) -> None:
|
|
22
|
+
if self.max_tokens < 1:
|
|
23
|
+
raise ValueError("`max_tokens` must be at least 1.")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class TGenerateResult:
|
|
28
|
+
text: str
|
|
29
|
+
confidence: float # 0..1 for the whole text, e.g. exp(mean token logprob)
|
|
30
|
+
usage: TUsage = field(default_factory=TUsage)
|
|
31
|
+
warnings: list[str] = field(default_factory=list)
|
|
32
|
+
# The generated tokens as (text, logprob), in order; empty if the server returns none.
|
|
33
|
+
tokens: list[tuple[str, float]] = field(default_factory=list)
|
|
34
|
+
|
|
35
|
+
def __post_init__(self) -> None:
|
|
36
|
+
if not 0 <= self.confidence <= 1:
|
|
37
|
+
raise ValueError(f"`confidence` must be between 0 and 1, got {self.confidence}.")
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Input screening: injection, abuse, off-topic use."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
from .defaults import DEFAULT_GUARD_THRESHOLD
|
|
9
|
+
from .t_input import TInputValue
|
|
10
|
+
from .t_meta import TMeta
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class TGuardRequest:
|
|
15
|
+
input: TInputValue
|
|
16
|
+
policies: Optional[dict[str, str]] = None # policy -> violation; None = DEFAULT_POLICIES
|
|
17
|
+
scope: Optional[str] = None # what the assistant is for; adds an off_topic check
|
|
18
|
+
threshold: float = DEFAULT_GUARD_THRESHOLD # block when a violation probability reaches this
|
|
19
|
+
|
|
20
|
+
def __post_init__(self) -> None:
|
|
21
|
+
if self.policies is not None and not self.policies:
|
|
22
|
+
raise ValueError("`policies` must not be empty.")
|
|
23
|
+
if not 0 <= self.threshold <= 1:
|
|
24
|
+
raise ValueError("`threshold` must be between 0 and 1.")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class TGuardCheck:
|
|
29
|
+
probability: float
|
|
30
|
+
flagged: bool
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class TGuardResponse:
|
|
35
|
+
allowed: bool
|
|
36
|
+
flagged: list[str]
|
|
37
|
+
checks: dict[str, TGuardCheck]
|
|
38
|
+
meta: TMeta
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""LLM evals: score an output against a rubric."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
from .defaults import DEFAULT_JUDGE_CRITERIA
|
|
9
|
+
from .t_input import TInputValue
|
|
10
|
+
from .t_meta import TMeta
|
|
11
|
+
from .t_question import check_levels
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class TJudgeRequest:
|
|
16
|
+
output: TInputValue # the answer being judged
|
|
17
|
+
input: Optional[TInputValue] = None # the prompt or task that produced it
|
|
18
|
+
reference: Optional[TInputValue] = None # optional reference answer
|
|
19
|
+
criteria: str = DEFAULT_JUDGE_CRITERIA
|
|
20
|
+
levels: Optional[list[str]] = None # ordered rubric, worst first; None = DEFAULT_RUBRIC
|
|
21
|
+
|
|
22
|
+
def __post_init__(self) -> None:
|
|
23
|
+
if self.levels is not None:
|
|
24
|
+
check_levels(self.levels)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class TJudgeResponse:
|
|
29
|
+
score: float # probability-weighted level, from 0 to len(levels) - 1
|
|
30
|
+
normalized: float # score / (len(levels) - 1), from 0 to 1
|
|
31
|
+
level: int
|
|
32
|
+
probabilities: dict[str, float]
|
|
33
|
+
legend: dict[str, str]
|
|
34
|
+
confidence: float
|
|
35
|
+
meta: TMeta
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""The engine contract's data: a rendered single-label prompt in, a
|
|
2
|
+
distribution over those labels out."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
|
|
8
|
+
from .t_usage import TUsage
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class TLabelQuery:
|
|
13
|
+
"""One question, rendered and ready for an engine."""
|
|
14
|
+
|
|
15
|
+
system: str
|
|
16
|
+
user: str
|
|
17
|
+
labels: list[str]
|
|
18
|
+
# Semantic parts, for engines that don't use the rendered prompt (e.g. mock).
|
|
19
|
+
input_text: str
|
|
20
|
+
question: str
|
|
21
|
+
descriptions: list[str]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class TLabelResult:
|
|
26
|
+
probabilities: dict[str, float]
|
|
27
|
+
usage: TUsage = field(default_factory=TUsage)
|
|
28
|
+
warnings: list[str] = field(default_factory=list)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class TPosition:
|
|
33
|
+
"""One generated token and the top alternatives the model considered there."""
|
|
34
|
+
|
|
35
|
+
token: str
|
|
36
|
+
candidates: list[tuple[str, float]] # (token, logprob)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Response metadata shared by every use case."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
from .t_usage import TUsage
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class TMeta:
|
|
12
|
+
engine: str
|
|
13
|
+
model: str
|
|
14
|
+
latency_ms: int
|
|
15
|
+
usage: TUsage
|
|
16
|
+
# Where this answer may be less faithful than a real decision model.
|
|
17
|
+
warnings: list[str] = field(default_factory=list)
|