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,121 @@
|
|
|
1
|
+
"""/v1/route, /v1/gate, /v1/classify: the use cases answered by one choice question."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Optional, Union
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, Field, field_validator, model_validator
|
|
8
|
+
|
|
9
|
+
from ....core.decision.defaults import DEFAULT_CLASSIFY_QUESTION
|
|
10
|
+
from ....core.decision.t_classify import TClassifyRequest, TClassifyResponse
|
|
11
|
+
from ....core.decision.t_gate import TGateRequest, TGateResponse, TToolCall
|
|
12
|
+
from ....core.decision.t_question import MAX_OPTIONS, check_option_keys
|
|
13
|
+
from ....core.decision.t_route import TRouteRequest, TRouteResponse
|
|
14
|
+
from .common import InputValue, Meta
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class _CoreValidated(BaseModel):
|
|
18
|
+
"""Constructs the core type after field validation, so core invariants give a 422."""
|
|
19
|
+
|
|
20
|
+
@model_validator(mode="after")
|
|
21
|
+
def _core_invariants(self): # type: ignore[no-untyped-def]
|
|
22
|
+
self.to_core() # type: ignore[attr-defined]
|
|
23
|
+
return self
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _distinct_keys(v: Optional[dict[str, str]]) -> Optional[dict[str, str]]:
|
|
27
|
+
# Checked per field too, so the 422 points at the offending field.
|
|
28
|
+
if v is not None:
|
|
29
|
+
check_option_keys(v)
|
|
30
|
+
return v
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class RouteRequest(_CoreValidated):
|
|
34
|
+
input: InputValue = Field(description="The prompt or task to route.")
|
|
35
|
+
routes: Optional[dict[str, str]] = Field(
|
|
36
|
+
None,
|
|
37
|
+
min_length=2,
|
|
38
|
+
max_length=MAX_OPTIONS,
|
|
39
|
+
description="Route name -> when to use it. Defaults to small / medium / large model tiers.",
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
_routes = field_validator("routes")(_distinct_keys)
|
|
43
|
+
|
|
44
|
+
def to_core(self) -> TRouteRequest:
|
|
45
|
+
return TRouteRequest(input=self.input, routes=self.routes)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class RouteResponse(BaseModel):
|
|
49
|
+
route: str
|
|
50
|
+
probabilities: dict[str, float]
|
|
51
|
+
confidence: float
|
|
52
|
+
meta: Meta
|
|
53
|
+
|
|
54
|
+
@classmethod
|
|
55
|
+
def from_core(cls, t: TRouteResponse) -> "RouteResponse":
|
|
56
|
+
return cls(route=t.route, probabilities=t.probabilities, confidence=t.confidence, meta=Meta.from_core(t.meta))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class ToolCall(BaseModel):
|
|
60
|
+
name: str
|
|
61
|
+
arguments: Union[dict[str, Any], str] = Field(default_factory=dict)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class GateRequest(_CoreValidated):
|
|
65
|
+
tool_call: ToolCall
|
|
66
|
+
context: Optional[InputValue] = Field(None, description="What the user asked for, or the recent conversation.")
|
|
67
|
+
policy: Optional[str] = Field(None, description="Your rules for tool use, in plain language.")
|
|
68
|
+
outcomes: Optional[dict[str, str]] = Field(
|
|
69
|
+
None,
|
|
70
|
+
min_length=2,
|
|
71
|
+
max_length=MAX_OPTIONS,
|
|
72
|
+
description="Outcome -> when it applies. Defaults to allow / ask / deny.",
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
_outcomes = field_validator("outcomes")(_distinct_keys)
|
|
76
|
+
|
|
77
|
+
def to_core(self) -> TGateRequest:
|
|
78
|
+
call = TToolCall(name=self.tool_call.name, arguments=self.tool_call.arguments)
|
|
79
|
+
return TGateRequest(tool_call=call, context=self.context, policy=self.policy, outcomes=self.outcomes)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class GateResponse(BaseModel):
|
|
83
|
+
decision: str
|
|
84
|
+
probabilities: dict[str, float]
|
|
85
|
+
confidence: float
|
|
86
|
+
meta: Meta
|
|
87
|
+
|
|
88
|
+
@classmethod
|
|
89
|
+
def from_core(cls, t: TGateResponse) -> "GateResponse":
|
|
90
|
+
return cls(decision=t.decision, probabilities=t.probabilities, confidence=t.confidence, meta=Meta.from_core(t.meta))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class ClassifyRequest(_CoreValidated):
|
|
94
|
+
input: Optional[InputValue] = Field(None, description="One item. Use `inputs` for a batch.")
|
|
95
|
+
inputs: Optional[list[InputValue]] = Field(None, description="Many items, labeled in parallel.")
|
|
96
|
+
labels: dict[str, str] = Field(min_length=2, max_length=MAX_OPTIONS)
|
|
97
|
+
question: str = DEFAULT_CLASSIFY_QUESTION
|
|
98
|
+
|
|
99
|
+
_labels = field_validator("labels")(_distinct_keys)
|
|
100
|
+
|
|
101
|
+
def to_core(self) -> TClassifyRequest:
|
|
102
|
+
return TClassifyRequest(labels=self.labels, input=self.input, inputs=self.inputs, question=self.question)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class Classification(BaseModel):
|
|
106
|
+
label: str
|
|
107
|
+
probabilities: dict[str, float]
|
|
108
|
+
confidence: float
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class ClassifyResponse(BaseModel):
|
|
112
|
+
results: list[Classification]
|
|
113
|
+
meta: Meta
|
|
114
|
+
|
|
115
|
+
@classmethod
|
|
116
|
+
def from_core(cls, t: TClassifyResponse) -> "ClassifyResponse":
|
|
117
|
+
results = [
|
|
118
|
+
Classification(label=r.label, probabilities=r.probabilities, confidence=r.confidence)
|
|
119
|
+
for r in t.results
|
|
120
|
+
]
|
|
121
|
+
return cls(results=results, meta=Meta.from_core(t.meta))
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Shared transport pieces: input values and response metadata."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Union
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, Field
|
|
8
|
+
|
|
9
|
+
from ....core.decision.t_meta import TMeta
|
|
10
|
+
|
|
11
|
+
InputValue = Union[str, dict[str, Any], list[Any]]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Usage(BaseModel):
|
|
15
|
+
input_tokens: int = 0
|
|
16
|
+
output_tokens: int = 0
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Meta(BaseModel):
|
|
20
|
+
engine: str
|
|
21
|
+
model: str
|
|
22
|
+
latency_ms: int
|
|
23
|
+
usage: Usage
|
|
24
|
+
warnings: list[str] = Field(
|
|
25
|
+
default_factory=list,
|
|
26
|
+
description="Where this answer may be less faithful than a real decision model.",
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
@classmethod
|
|
30
|
+
def from_core(cls, t: TMeta) -> "Meta":
|
|
31
|
+
return cls(
|
|
32
|
+
engine=t.engine,
|
|
33
|
+
model=t.model,
|
|
34
|
+
latency_ms=t.latency_ms,
|
|
35
|
+
usage=Usage(input_tokens=t.usage.input_tokens, output_tokens=t.usage.output_tokens),
|
|
36
|
+
warnings=list(t.warnings),
|
|
37
|
+
)
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""/v1/decide: three question types, answered about one input.
|
|
2
|
+
|
|
3
|
+
Invariants (option counts, distinct keys) live in the core types; the
|
|
4
|
+
validators below construct them so a violation becomes a 422.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Annotated, Literal, Optional, Union
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel, Field, model_validator
|
|
12
|
+
|
|
13
|
+
from ....core.decision.t_answer import TAnswer, TBinaryAnswer, TChoiceAnswer
|
|
14
|
+
from ....core.decision.t_decide import TDecideRequest, TDecideResponse
|
|
15
|
+
from ....core.decision.t_question import (
|
|
16
|
+
MAX_LEVELS,
|
|
17
|
+
MAX_OPTIONS,
|
|
18
|
+
TBinaryQuestion,
|
|
19
|
+
TChoiceQuestion,
|
|
20
|
+
TQuestion,
|
|
21
|
+
TScaleQuestion,
|
|
22
|
+
)
|
|
23
|
+
from .common import InputValue, Meta
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class BinaryQuestion(BaseModel):
|
|
27
|
+
type: Literal["binary"]
|
|
28
|
+
question: str = Field(description="A yes/no question or a statement to verify.")
|
|
29
|
+
yes: Optional[str] = Field(None, description="Optional: what counts as yes.")
|
|
30
|
+
no: Optional[str] = Field(None, description="Optional: what counts as no.")
|
|
31
|
+
|
|
32
|
+
def to_core(self) -> TBinaryQuestion:
|
|
33
|
+
return TBinaryQuestion(question=self.question, yes=self.yes, no=self.no)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ChoiceQuestion(BaseModel):
|
|
37
|
+
type: Literal["choice"]
|
|
38
|
+
question: str
|
|
39
|
+
options: dict[str, str] = Field(
|
|
40
|
+
min_length=2,
|
|
41
|
+
max_length=MAX_OPTIONS,
|
|
42
|
+
description="Option key -> description. Keys are returned as the answer.",
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
@model_validator(mode="after")
|
|
46
|
+
def _core_invariants(self) -> "ChoiceQuestion":
|
|
47
|
+
self.to_core()
|
|
48
|
+
return self
|
|
49
|
+
|
|
50
|
+
def to_core(self) -> TChoiceQuestion:
|
|
51
|
+
return TChoiceQuestion(question=self.question, options=dict(self.options))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class ScaleQuestion(BaseModel):
|
|
55
|
+
type: Literal["scale"]
|
|
56
|
+
question: str
|
|
57
|
+
levels: list[str] = Field(
|
|
58
|
+
min_length=2,
|
|
59
|
+
max_length=MAX_LEVELS,
|
|
60
|
+
description="Ordered level descriptions, lowest first. Level i is returned as i.",
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
def to_core(self) -> TScaleQuestion:
|
|
64
|
+
return TScaleQuestion(question=self.question, levels=list(self.levels))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
Question = Annotated[
|
|
68
|
+
Union[BinaryQuestion, ChoiceQuestion, ScaleQuestion], Field(discriminator="type")
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class DecideRequest(BaseModel):
|
|
73
|
+
input: InputValue = Field(description="The content to decide about: text, an object, or a list.")
|
|
74
|
+
questions: dict[str, Question] = Field(min_length=1)
|
|
75
|
+
model: Optional[str] = Field(None, description="Ignored; the server's configured model is used.")
|
|
76
|
+
|
|
77
|
+
def to_core(self) -> TDecideRequest:
|
|
78
|
+
questions: dict[str, TQuestion] = {k: q.to_core() for k, q in self.questions.items()}
|
|
79
|
+
return TDecideRequest(input=self.input, questions=questions)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class BinaryAnswer(BaseModel):
|
|
83
|
+
type: Literal["binary"] = "binary"
|
|
84
|
+
value: bool
|
|
85
|
+
probability: float = Field(description="Probability that the answer is yes.")
|
|
86
|
+
confidence: float
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class ChoiceAnswer(BaseModel):
|
|
90
|
+
type: Literal["choice"] = "choice"
|
|
91
|
+
value: str
|
|
92
|
+
probabilities: dict[str, float]
|
|
93
|
+
confidence: float
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class ScaleAnswer(BaseModel):
|
|
97
|
+
type: Literal["scale"] = "scale"
|
|
98
|
+
value: float = Field(description="Probability-weighted level, from 0 to len(levels) - 1.")
|
|
99
|
+
level: int = Field(description="The single most likely level.")
|
|
100
|
+
probabilities: dict[str, float]
|
|
101
|
+
legend: dict[str, str]
|
|
102
|
+
confidence: float
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
Answer = Annotated[Union[BinaryAnswer, ChoiceAnswer, ScaleAnswer], Field(discriminator="type")]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def answer_from_core(a: TAnswer) -> Union[BinaryAnswer, ChoiceAnswer, ScaleAnswer]:
|
|
109
|
+
if isinstance(a, TBinaryAnswer):
|
|
110
|
+
return BinaryAnswer(value=a.value, probability=a.probability, confidence=a.confidence)
|
|
111
|
+
if isinstance(a, TChoiceAnswer):
|
|
112
|
+
return ChoiceAnswer(value=a.value, probabilities=a.probabilities, confidence=a.confidence)
|
|
113
|
+
return ScaleAnswer(
|
|
114
|
+
value=a.value,
|
|
115
|
+
level=a.level,
|
|
116
|
+
probabilities=a.probabilities,
|
|
117
|
+
legend=a.legend,
|
|
118
|
+
confidence=a.confidence,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class DecideResponse(BaseModel):
|
|
123
|
+
answers: dict[str, Answer]
|
|
124
|
+
meta: Meta
|
|
125
|
+
|
|
126
|
+
@classmethod
|
|
127
|
+
def from_core(cls, t: TDecideResponse) -> "DecideResponse":
|
|
128
|
+
return cls(
|
|
129
|
+
answers={k: answer_from_core(a) for k, a in t.answers.items()},
|
|
130
|
+
meta=Meta.from_core(t.meta),
|
|
131
|
+
)
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""/v1/extract: typed fields pulled out of one input.
|
|
2
|
+
|
|
3
|
+
Invariants (field types, enum options, field counts) live in the core types;
|
|
4
|
+
the validators below construct them so a violation becomes a 422 that points
|
|
5
|
+
at the offending field.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any, Literal, Optional, Union
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel, Field, model_serializer, model_validator
|
|
13
|
+
|
|
14
|
+
from ....core.decision.t_extract import (
|
|
15
|
+
MAX_FIELDS,
|
|
16
|
+
TExtractField,
|
|
17
|
+
TExtractRequest,
|
|
18
|
+
TExtractResponse,
|
|
19
|
+
TFieldValue,
|
|
20
|
+
)
|
|
21
|
+
from ....core.decision.t_question import MAX_OPTIONS
|
|
22
|
+
from .common import InputValue, Meta
|
|
23
|
+
|
|
24
|
+
Value = Optional[Union[bool, int, float, str]]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ExtractField(BaseModel):
|
|
28
|
+
type: Literal["string", "number", "integer", "boolean", "enum"] = Field(
|
|
29
|
+
description="string, number, and integer values are generated; enum and boolean are scored."
|
|
30
|
+
)
|
|
31
|
+
description: Optional[str] = Field(None, description="What the field means and how to format it.")
|
|
32
|
+
options: Optional[Union[list[str], dict[str, str]]] = Field(
|
|
33
|
+
None,
|
|
34
|
+
min_length=2,
|
|
35
|
+
max_length=MAX_OPTIONS,
|
|
36
|
+
description="Enum only: option names, or option -> description. The chosen name is returned.",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
@model_validator(mode="after")
|
|
40
|
+
def _core_invariants(self) -> "ExtractField":
|
|
41
|
+
self.to_core()
|
|
42
|
+
return self
|
|
43
|
+
|
|
44
|
+
def to_core(self) -> TExtractField:
|
|
45
|
+
options = list(self.options) if isinstance(self.options, list) else self.options
|
|
46
|
+
return TExtractField(type=self.type, description=self.description, options=options)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ExtractRequest(BaseModel):
|
|
50
|
+
input: InputValue = Field(description="The content to extract from: text, an object, or a list.")
|
|
51
|
+
fields: dict[str, ExtractField] = Field(
|
|
52
|
+
min_length=1, max_length=MAX_FIELDS, description="Field name -> type and description."
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
@model_validator(mode="after")
|
|
56
|
+
def _core_invariants(self) -> "ExtractRequest":
|
|
57
|
+
self.to_core()
|
|
58
|
+
return self
|
|
59
|
+
|
|
60
|
+
def to_core(self) -> TExtractRequest:
|
|
61
|
+
return TExtractRequest(input=self.input, fields={k: f.to_core() for k, f in self.fields.items()})
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class FieldValue(BaseModel):
|
|
65
|
+
value: Value = Field(None, description="The extracted value; null when the input doesn't contain it.")
|
|
66
|
+
confidence: float
|
|
67
|
+
probabilities: Optional[dict[str, float]] = Field(None, description="Enum fields only.")
|
|
68
|
+
probability: Optional[float] = Field(None, description="Boolean fields only: probability of true.")
|
|
69
|
+
|
|
70
|
+
@model_serializer(mode="wrap")
|
|
71
|
+
def _omit_absent(self, handler: Any) -> dict[str, Any]:
|
|
72
|
+
# `value: null` is an answer; a missing probability is not, so omit only those.
|
|
73
|
+
data = handler(self)
|
|
74
|
+
rest = {k: v for k, v in data.items() if k != "value" and v is not None}
|
|
75
|
+
return {"value": self.value, **rest}
|
|
76
|
+
|
|
77
|
+
@classmethod
|
|
78
|
+
def from_core(cls, t: TFieldValue) -> "FieldValue":
|
|
79
|
+
return cls(value=t.value, confidence=t.confidence, probabilities=t.probabilities, probability=t.probability)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class ExtractResponse(BaseModel):
|
|
83
|
+
values: dict[str, Value] = Field(description="Field name -> value, for direct use.")
|
|
84
|
+
fields: dict[str, FieldValue] = Field(description="Field name -> value with its confidence.")
|
|
85
|
+
meta: Meta
|
|
86
|
+
|
|
87
|
+
@classmethod
|
|
88
|
+
def from_core(cls, t: TExtractResponse) -> "ExtractResponse":
|
|
89
|
+
return cls(
|
|
90
|
+
values=dict(t.values),
|
|
91
|
+
fields={k: FieldValue.from_core(f) for k, f in t.fields.items()},
|
|
92
|
+
meta=Meta.from_core(t.meta),
|
|
93
|
+
)
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""/v1/guard (binary questions per policy) and /v1/judge (one scale question)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, Field, model_validator
|
|
8
|
+
|
|
9
|
+
from ....core.decision.defaults import DEFAULT_GUARD_THRESHOLD, DEFAULT_JUDGE_CRITERIA
|
|
10
|
+
from ....core.decision.t_guard import TGuardRequest, TGuardResponse
|
|
11
|
+
from ....core.decision.t_judge import TJudgeRequest, TJudgeResponse
|
|
12
|
+
from ....core.decision.t_question import MAX_LEVELS
|
|
13
|
+
from .common import InputValue, Meta
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class GuardRequest(BaseModel):
|
|
17
|
+
input: InputValue
|
|
18
|
+
policies: Optional[dict[str, str]] = Field(
|
|
19
|
+
None,
|
|
20
|
+
min_length=1,
|
|
21
|
+
description="Policy name -> description of a violation. Defaults to prompt_injection and abuse.",
|
|
22
|
+
)
|
|
23
|
+
scope: Optional[str] = Field(
|
|
24
|
+
None, description="Optional: what the assistant is for. Adds an off_topic check."
|
|
25
|
+
)
|
|
26
|
+
threshold: float = Field(
|
|
27
|
+
DEFAULT_GUARD_THRESHOLD, ge=0, le=1, description="Block when any violation probability reaches this."
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
@model_validator(mode="after")
|
|
31
|
+
def _core_invariants(self) -> "GuardRequest":
|
|
32
|
+
self.to_core()
|
|
33
|
+
return self
|
|
34
|
+
|
|
35
|
+
def to_core(self) -> TGuardRequest:
|
|
36
|
+
return TGuardRequest(input=self.input, policies=self.policies, scope=self.scope, threshold=self.threshold)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class GuardCheck(BaseModel):
|
|
40
|
+
probability: float
|
|
41
|
+
flagged: bool
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class GuardResponse(BaseModel):
|
|
45
|
+
allowed: bool
|
|
46
|
+
flagged: list[str]
|
|
47
|
+
checks: dict[str, GuardCheck]
|
|
48
|
+
meta: Meta
|
|
49
|
+
|
|
50
|
+
@classmethod
|
|
51
|
+
def from_core(cls, t: TGuardResponse) -> "GuardResponse":
|
|
52
|
+
return cls(
|
|
53
|
+
allowed=t.allowed,
|
|
54
|
+
flagged=list(t.flagged),
|
|
55
|
+
checks={k: GuardCheck(probability=c.probability, flagged=c.flagged) for k, c in t.checks.items()},
|
|
56
|
+
meta=Meta.from_core(t.meta),
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class JudgeRequest(BaseModel):
|
|
61
|
+
output: InputValue = Field(description="The answer being judged.")
|
|
62
|
+
input: Optional[InputValue] = Field(None, description="The prompt or task that produced the output.")
|
|
63
|
+
reference: Optional[InputValue] = Field(None, description="Optional reference answer.")
|
|
64
|
+
criteria: str = DEFAULT_JUDGE_CRITERIA
|
|
65
|
+
levels: Optional[list[str]] = Field(
|
|
66
|
+
None,
|
|
67
|
+
min_length=2,
|
|
68
|
+
max_length=MAX_LEVELS,
|
|
69
|
+
description="Ordered rubric levels, worst first. Defaults to a 5-level rubric (0-4).",
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
def to_core(self) -> TJudgeRequest:
|
|
73
|
+
return TJudgeRequest(
|
|
74
|
+
output=self.output, input=self.input, reference=self.reference, criteria=self.criteria, levels=self.levels
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class JudgeResponse(BaseModel):
|
|
79
|
+
score: float = Field(description="Probability-weighted level, from 0 to len(levels) - 1.")
|
|
80
|
+
normalized: float = Field(description="score / (len(levels) - 1), from 0 to 1.")
|
|
81
|
+
level: int
|
|
82
|
+
probabilities: dict[str, float]
|
|
83
|
+
legend: dict[str, str]
|
|
84
|
+
confidence: float
|
|
85
|
+
meta: Meta
|
|
86
|
+
|
|
87
|
+
@classmethod
|
|
88
|
+
def from_core(cls, t: TJudgeResponse) -> "JudgeResponse":
|
|
89
|
+
return cls(
|
|
90
|
+
score=t.score,
|
|
91
|
+
normalized=t.normalized,
|
|
92
|
+
level=t.level,
|
|
93
|
+
probabilities=t.probabilities,
|
|
94
|
+
legend=t.legend,
|
|
95
|
+
confidence=t.confidence,
|
|
96
|
+
meta=Meta.from_core(t.meta),
|
|
97
|
+
)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""/v1/rerank: Cohere-style wire format, the de facto standard shared by
|
|
2
|
+
Hugging Face TEI, Infinity, Jina, and vLLM."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from typing import Optional, Union
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, Field
|
|
9
|
+
|
|
10
|
+
from ....core.decision.t_rerank import TRerankRequest, TRerankResponse
|
|
11
|
+
from .common import Meta
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class RerankDocument(BaseModel):
|
|
15
|
+
text: str
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class RerankRequest(BaseModel):
|
|
19
|
+
query: str
|
|
20
|
+
documents: list[Union[str, RerankDocument]] = Field(min_length=1)
|
|
21
|
+
top_n: Optional[int] = Field(None, ge=1)
|
|
22
|
+
return_documents: bool = False
|
|
23
|
+
model: Optional[str] = Field(None, description="Ignored; the server's configured model is used.")
|
|
24
|
+
|
|
25
|
+
def texts(self) -> list[str]:
|
|
26
|
+
return [d if isinstance(d, str) else d.text for d in self.documents]
|
|
27
|
+
|
|
28
|
+
def to_core(self) -> TRerankRequest:
|
|
29
|
+
return TRerankRequest(
|
|
30
|
+
query=self.query, documents=self.texts(), top_n=self.top_n, return_documents=self.return_documents
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class RerankResult(BaseModel):
|
|
35
|
+
index: int
|
|
36
|
+
relevance_score: float
|
|
37
|
+
document: Optional[RerankDocument] = None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class RerankResponse(BaseModel):
|
|
41
|
+
id: str
|
|
42
|
+
results: list[RerankResult]
|
|
43
|
+
meta: Meta
|
|
44
|
+
|
|
45
|
+
@classmethod
|
|
46
|
+
def from_core(cls, t: TRerankResponse) -> "RerankResponse":
|
|
47
|
+
results = [
|
|
48
|
+
RerankResult(
|
|
49
|
+
index=r.index,
|
|
50
|
+
relevance_score=r.relevance_score,
|
|
51
|
+
document=RerankDocument(text=r.document) if r.document is not None else None,
|
|
52
|
+
)
|
|
53
|
+
for r in t.results
|
|
54
|
+
]
|
|
55
|
+
return cls(id=t.id, results=results, meta=Meta.from_core(t.meta))
|
dev_double/client.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Core layer: interfaces, domain types, and pure decision logic. No third-party imports."""
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Decision core: interfaces, domain types, prompts, and the use cases.
|
|
2
|
+
|
|
3
|
+
Zero third-party dependencies. Side effects (model calls, time, ids) arrive
|
|
4
|
+
through the ports ``IEngine`` / ``IDecider``, ``IClock``, and ``IIdProvider``.
|
|
5
|
+
Optional capabilities (``IGenerator``, ``IRecordReader``, ``IExtractor``,
|
|
6
|
+
``IReranker``) are detected with ``isinstance`` and used when present.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from .confidence import DIGITS, confidence
|
|
10
|
+
from .decider_basic_impl import DeciderBasicImpl
|
|
11
|
+
from .decision_service_basic_impl import DecisionServiceBasicImpl
|
|
12
|
+
from .defaults import DEFAULT_OUTCOMES, DEFAULT_POLICIES, DEFAULT_ROUTES, DEFAULT_RUBRIC
|
|
13
|
+
from .errors import EngineError
|
|
14
|
+
from .i_clock import IClock
|
|
15
|
+
from .i_decider import IDecider
|
|
16
|
+
from .i_decision_service import IDecisionService
|
|
17
|
+
from .i_engine import IEngine
|
|
18
|
+
from .i_extractor import IExtractor
|
|
19
|
+
from .i_generator import IGenerator
|
|
20
|
+
from .i_id_provider import IIdProvider
|
|
21
|
+
from .i_record_reader import IRecordReader
|
|
22
|
+
from .i_reranker import IReranker
|
|
23
|
+
from .t_answer import TAnswer, TBinaryAnswer, TChoiceAnswer, TScaleAnswer
|
|
24
|
+
from .t_classify import TClassification, TClassifyRequest, TClassifyResponse
|
|
25
|
+
from .t_decide import TDecideRequest, TDecideResponse
|
|
26
|
+
from .t_extract import MAX_FIELDS, TExtractField, TExtractRequest, TExtractResponse, TFieldValue
|
|
27
|
+
from .t_gate import TGateRequest, TGateResponse, TToolCall
|
|
28
|
+
from .t_generate import TGenerateQuery, TGenerateResult
|
|
29
|
+
from .t_guard import TGuardCheck, TGuardRequest, TGuardResponse
|
|
30
|
+
from .t_input import TInputValue
|
|
31
|
+
from .t_judge import TJudgeRequest, TJudgeResponse
|
|
32
|
+
from .t_label_query import TLabelQuery, TLabelResult, TPosition
|
|
33
|
+
from .t_meta import TMeta
|
|
34
|
+
from .t_question import (
|
|
35
|
+
MAX_LEVELS,
|
|
36
|
+
MAX_OPTIONS,
|
|
37
|
+
TBinaryQuestion,
|
|
38
|
+
TChoiceQuestion,
|
|
39
|
+
TQuestion,
|
|
40
|
+
TScaleQuestion,
|
|
41
|
+
)
|
|
42
|
+
from .t_rerank import TRerankRequest, TRerankResponse, TRerankResult
|
|
43
|
+
from .t_route import TRouteRequest, TRouteResponse
|
|
44
|
+
from .t_usage import TUsage
|
|
45
|
+
from .tracker import Tracker
|
|
46
|
+
|
|
47
|
+
__all__ = [
|
|
48
|
+
"DEFAULT_OUTCOMES", "DEFAULT_POLICIES", "DEFAULT_ROUTES", "DEFAULT_RUBRIC", "DIGITS",
|
|
49
|
+
"MAX_FIELDS", "MAX_LEVELS", "MAX_OPTIONS",
|
|
50
|
+
"DeciderBasicImpl", "DecisionServiceBasicImpl", "EngineError",
|
|
51
|
+
"IClock", "IDecider", "IDecisionService", "IEngine", "IExtractor",
|
|
52
|
+
"IGenerator", "IIdProvider", "IRecordReader", "IReranker",
|
|
53
|
+
"TExtractField", "TExtractRequest", "TExtractResponse", "TFieldValue",
|
|
54
|
+
"TGenerateQuery", "TGenerateResult",
|
|
55
|
+
"TAnswer", "TBinaryAnswer", "TBinaryQuestion", "TChoiceAnswer", "TChoiceQuestion",
|
|
56
|
+
"TClassification", "TClassifyRequest", "TClassifyResponse", "TDecideRequest",
|
|
57
|
+
"TDecideResponse", "TGateRequest", "TGateResponse", "TGuardCheck", "TGuardRequest",
|
|
58
|
+
"TGuardResponse", "TInputValue", "TJudgeRequest", "TJudgeResponse", "TLabelQuery",
|
|
59
|
+
"TLabelResult", "TMeta", "TPosition", "TQuestion", "TRerankRequest", "TRerankResponse",
|
|
60
|
+
"TRerankResult", "TRouteRequest", "TRouteResponse", "TScaleAnswer", "TScaleQuestion",
|
|
61
|
+
"TToolCall", "TUsage", "Tracker", "confidence",
|
|
62
|
+
]
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Turns a distribution over a question's labels into its typed answer.
|
|
2
|
+
|
|
3
|
+
Labels are "yes"/"no" for binary questions, option keys for choice questions,
|
|
4
|
+
and "0".."n-1" for scale questions (see ``question_labels``).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from .confidence import DIGITS, confidence, round_probabilities
|
|
10
|
+
from .t_answer import TAnswer, TBinaryAnswer, TChoiceAnswer, TScaleAnswer
|
|
11
|
+
from .t_question import TBinaryQuestion, TChoiceQuestion, TQuestion
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def question_labels(question: TQuestion) -> dict[str, str]:
|
|
15
|
+
"""Label -> description, in the order the question defines them."""
|
|
16
|
+
if isinstance(question, TBinaryQuestion):
|
|
17
|
+
return {"yes": question.yes or "The answer is yes.", "no": question.no or "The answer is no."}
|
|
18
|
+
if isinstance(question, TChoiceQuestion):
|
|
19
|
+
return dict(question.options)
|
|
20
|
+
return {str(i): text for i, text in enumerate(question.levels)}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def shape_answer(question: TQuestion, probs: dict[str, float], round_probs: bool = True) -> TAnswer:
|
|
24
|
+
"""``round_probs=False`` passes choice/scale probabilities through as given."""
|
|
25
|
+
if isinstance(question, TBinaryQuestion):
|
|
26
|
+
p_yes = probs["yes"]
|
|
27
|
+
return TBinaryAnswer(
|
|
28
|
+
value=p_yes >= 0.5,
|
|
29
|
+
probability=round(p_yes, DIGITS),
|
|
30
|
+
confidence=round(confidence([p_yes, 1 - p_yes]), DIGITS),
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
shown = round_probabilities(probs) if round_probs else probs
|
|
34
|
+
if isinstance(question, TChoiceQuestion):
|
|
35
|
+
return TChoiceAnswer(
|
|
36
|
+
value=max(probs, key=probs.__getitem__),
|
|
37
|
+
probabilities=shown,
|
|
38
|
+
confidence=round(confidence(list(probs.values())), DIGITS),
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
expected = sum(int(level) * p for level, p in probs.items())
|
|
42
|
+
return TScaleAnswer(
|
|
43
|
+
value=round(expected, DIGITS),
|
|
44
|
+
level=int(max(probs, key=probs.__getitem__)),
|
|
45
|
+
probabilities=shown,
|
|
46
|
+
legend={str(i): text for i, text in enumerate(question.levels)},
|
|
47
|
+
confidence=round(confidence(list(probs.values())), DIGITS),
|
|
48
|
+
)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""How concentrated a distribution is, and output rounding."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
DIGITS = 4
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def confidence(probabilities: list[float]) -> float:
|
|
9
|
+
"""How concentrated a distribution is: 1.0 when all mass is on one label,
|
|
10
|
+
0.0 when it is spread evenly. Normalized max probability: (n*max - 1) / (n - 1).
|
|
11
|
+
"""
|
|
12
|
+
n = len(probabilities)
|
|
13
|
+
if n < 2:
|
|
14
|
+
return 1.0
|
|
15
|
+
return max(0.0, (n * max(probabilities) - 1) / (n - 1))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def round_probabilities(probs: dict[str, float]) -> dict[str, float]:
|
|
19
|
+
return {k: round(v, DIGITS) for k, v in probs.items()}
|