decision-ui 0.1.0__tar.gz
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.
- decision_ui-0.1.0/PKG-INFO +52 -0
- decision_ui-0.1.0/README.md +36 -0
- decision_ui-0.1.0/pyproject.toml +26 -0
- decision_ui-0.1.0/pyproject.toml.orig +25 -0
- decision_ui-0.1.0/src/decision_ui/__init__.py +7 -0
- decision_ui-0.1.0/src/decision_ui/backends/__init__.py +20 -0
- decision_ui-0.1.0/src/decision_ui/backends/base.py +82 -0
- decision_ui-0.1.0/src/decision_ui/backends/mock.py +28 -0
- decision_ui-0.1.0/src/decision_ui/backends/vllm.py +72 -0
- decision_ui-0.1.0/src/decision_ui/cli/__init__.py +0 -0
- decision_ui-0.1.0/src/decision_ui/cli/main.py +6 -0
- decision_ui-0.1.0/src/decision_ui/cli/serve_commands.py +28 -0
- decision_ui-0.1.0/src/decision_ui/core/__init__.py +14 -0
- decision_ui-0.1.0/src/decision_ui/core/data_types.py +126 -0
- decision_ui-0.1.0/src/decision_ui/core/utilities.py +56 -0
- decision_ui-0.1.0/src/decision_ui/prompt_renders/__init__.py +13 -0
- decision_ui-0.1.0/src/decision_ui/prompt_renders/default.py +19 -0
- decision_ui-0.1.0/src/decision_ui/server/__init__.py +1 -0
- decision_ui-0.1.0/src/decision_ui/server/routes/__init__.py +0 -0
- decision_ui-0.1.0/src/decision_ui/server/routes/decision.py +32 -0
- decision_ui-0.1.0/src/decision_ui/server/routes/decisions.py +67 -0
- decision_ui-0.1.0/src/decision_ui/server/routes/systemone.py +32 -0
- decision_ui-0.1.0/src/decision_ui/server/server.py +34 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: decision-ui
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Add your description here
|
|
5
|
+
Author: panuthept
|
|
6
|
+
Author-email: panuthept <falcon_270394@hotmail.co.th>
|
|
7
|
+
Requires-Dist: fastapi>=0.143.0
|
|
8
|
+
Requires-Dist: openai>=3.29.0
|
|
9
|
+
Requires-Dist: pydantic>=2.14.1
|
|
10
|
+
Requires-Dist: transformers>=5.19.0
|
|
11
|
+
Requires-Dist: typer>=0.27.3
|
|
12
|
+
Requires-Dist: typesafe-sdk>=0.7.4
|
|
13
|
+
Requires-Dist: uvicorn>=0.54.0
|
|
14
|
+
Requires-Python: >=3.14
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# DecisionUI
|
|
18
|
+
A decision interface server, compatible with SystemOne API and OpenAI's Decisions API.
|
|
19
|
+
|
|
20
|
+
## Usage
|
|
21
|
+
Install DecisionUI
|
|
22
|
+
```bash
|
|
23
|
+
pip install decision-ui
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Deploy the model using any inference engine, e.g., vLLM.
|
|
27
|
+
```bash
|
|
28
|
+
vllm serve google/gemma-4-31B-it --host localhost --port 8000
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Start a DecisionUI server
|
|
32
|
+
```bash
|
|
33
|
+
decision-ui serve --base_url http://localhost:8000 --host localhost --port 9000
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Use it via TypeSafeClient
|
|
37
|
+
```python
|
|
38
|
+
from typesafe_sdk import Choice, TypeSafeClient
|
|
39
|
+
|
|
40
|
+
with TypeSafeClient(base_url="http://localhost:9000", api_key="EMPTY") as client:
|
|
41
|
+
decision = client.system_one(
|
|
42
|
+
model="google/gemma-4-31B-it",
|
|
43
|
+
state={"document": "I was charged twice. Please fix this ASAP."},
|
|
44
|
+
questions={
|
|
45
|
+
"category": Choice(
|
|
46
|
+
instructions="What is this ticket about?",
|
|
47
|
+
criteria={"billing": None, "technical": None, "other": None},
|
|
48
|
+
),
|
|
49
|
+
},
|
|
50
|
+
)
|
|
51
|
+
print(decision.choices["category"].probabilities)
|
|
52
|
+
```
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# DecisionUI
|
|
2
|
+
A decision interface server, compatible with SystemOne API and OpenAI's Decisions API.
|
|
3
|
+
|
|
4
|
+
## Usage
|
|
5
|
+
Install DecisionUI
|
|
6
|
+
```bash
|
|
7
|
+
pip install decision-ui
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
Deploy the model using any inference engine, e.g., vLLM.
|
|
11
|
+
```bash
|
|
12
|
+
vllm serve google/gemma-4-31B-it --host localhost --port 8000
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Start a DecisionUI server
|
|
16
|
+
```bash
|
|
17
|
+
decision-ui serve --base_url http://localhost:8000 --host localhost --port 9000
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Use it via TypeSafeClient
|
|
21
|
+
```python
|
|
22
|
+
from typesafe_sdk import Choice, TypeSafeClient
|
|
23
|
+
|
|
24
|
+
with TypeSafeClient(base_url="http://localhost:9000", api_key="EMPTY") as client:
|
|
25
|
+
decision = client.system_one(
|
|
26
|
+
model="google/gemma-4-31B-it",
|
|
27
|
+
state={"document": "I was charged twice. Please fix this ASAP."},
|
|
28
|
+
questions={
|
|
29
|
+
"category": Choice(
|
|
30
|
+
instructions="What is this ticket about?",
|
|
31
|
+
criteria={"billing": None, "technical": None, "other": None},
|
|
32
|
+
),
|
|
33
|
+
},
|
|
34
|
+
)
|
|
35
|
+
print(decision.choices["category"].probabilities)
|
|
36
|
+
```
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "decision-ui"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Add your description here"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.14"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"fastapi>=0.143.0",
|
|
9
|
+
"openai>=3.29.0",
|
|
10
|
+
"pydantic>=2.14.1",
|
|
11
|
+
"transformers>=5.19.0",
|
|
12
|
+
"typer>=0.27.3",
|
|
13
|
+
"typesafe-sdk>=0.7.4",
|
|
14
|
+
"uvicorn>=0.54.0",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[[project.authors]]
|
|
18
|
+
name = "panuthept"
|
|
19
|
+
email = "falcon_270394@hotmail.co.th"
|
|
20
|
+
|
|
21
|
+
[project.scripts]
|
|
22
|
+
decision-ui = "decision_ui.cli.main:entry_point"
|
|
23
|
+
|
|
24
|
+
[build-system]
|
|
25
|
+
requires = ["uv_build>=0.12.24,<0.13.0"]
|
|
26
|
+
build-backend = "uv_build"
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "decision-ui"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Add your description here"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "panuthept", email = "falcon_270394@hotmail.co.th" }
|
|
8
|
+
]
|
|
9
|
+
requires-python = ">=3.14"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"fastapi>=0.143.0",
|
|
12
|
+
"openai>=3.29.0",
|
|
13
|
+
"pydantic>=2.14.1",
|
|
14
|
+
"transformers>=5.19.0",
|
|
15
|
+
"typer>=0.27.3",
|
|
16
|
+
"typesafe-sdk>=0.7.4",
|
|
17
|
+
"uvicorn>=0.54.0",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[project.scripts]
|
|
21
|
+
decision-ui = "decision_ui.cli.main:entry_point"
|
|
22
|
+
|
|
23
|
+
[build-system]
|
|
24
|
+
requires = ["uv_build>=0.12.24,<0.13.0"]
|
|
25
|
+
build-backend = "uv_build"
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Backend registry."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .base import Backend
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def get_backend(name: str = "mock", **kwargs) -> Backend:
|
|
9
|
+
if name == "mock":
|
|
10
|
+
from decision_ui.backends.mock import MockBackend
|
|
11
|
+
|
|
12
|
+
return MockBackend()
|
|
13
|
+
if name == "vllm":
|
|
14
|
+
from decision_ui.backends.vllm import VLLMBackend
|
|
15
|
+
|
|
16
|
+
return VLLMBackend(**kwargs)
|
|
17
|
+
raise ValueError(f"Unknown backend '{name}'. Available: mock and vllm.")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
__all__ = ["Backend", "get_backend"]
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Backend interface.
|
|
2
|
+
|
|
3
|
+
A backend turns ``(state, question)`` pairs into option logits. Every backend must
|
|
4
|
+
implement :meth:`score_options`, which is the single primitive the rest of OpenJev
|
|
5
|
+
builds on. Choice / Score / Noul are all expressed as "score these labels given this
|
|
6
|
+
prompt", which is exactly one forward pass on any causal LM.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import time
|
|
12
|
+
from typing import Callable
|
|
13
|
+
from abc import ABC, abstractmethod
|
|
14
|
+
from decision_ui.prompt_renders import get_prompt_render as default_prompt_render
|
|
15
|
+
|
|
16
|
+
from decision_ui.core.data_types import (
|
|
17
|
+
Answer,
|
|
18
|
+
Choice,
|
|
19
|
+
Noul,
|
|
20
|
+
Score,
|
|
21
|
+
SystemOneRequest,
|
|
22
|
+
SystemOneResponse,
|
|
23
|
+
Usage,
|
|
24
|
+
)
|
|
25
|
+
from decision_ui.core.utilities import (
|
|
26
|
+
build_choice_answer,
|
|
27
|
+
build_noul_answer,
|
|
28
|
+
build_score_answer,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
NOUL_LABELS = ["yes", "no"]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Backend(ABC):
|
|
35
|
+
def __init__(self, prompt_render: Callable = None, **kwargs):
|
|
36
|
+
self.prompt_render = prompt_render or default_prompt_render
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
@abstractmethod
|
|
40
|
+
def available_models(self) -> list[str]:
|
|
41
|
+
"""Return a list of available model IDs."""
|
|
42
|
+
|
|
43
|
+
@abstractmethod
|
|
44
|
+
def score_options(self, model: str, prompt: str, labels: list[str]) -> tuple[list[float], int]:
|
|
45
|
+
"""Return one logit per label plus the number of input tokens consumed."""
|
|
46
|
+
|
|
47
|
+
def decide(self, request: SystemOneRequest, temperature: float = 1.0) -> SystemOneResponse:
|
|
48
|
+
t0 = time.perf_counter()
|
|
49
|
+
answers: dict[str, Answer] = {}
|
|
50
|
+
usage = Usage()
|
|
51
|
+
for qid, q in request.questions.items():
|
|
52
|
+
if isinstance(q, Choice):
|
|
53
|
+
labels = q.option_keys()
|
|
54
|
+
logits, n_in = self.score_options(
|
|
55
|
+
request.model,
|
|
56
|
+
self.prompt_render(request.state, q.instructions, labels),
|
|
57
|
+
labels
|
|
58
|
+
)
|
|
59
|
+
answers[qid] = build_choice_answer(q, logits, temperature)
|
|
60
|
+
elif isinstance(q, Score):
|
|
61
|
+
labels = [f"{lvl} ({q.legend[str(lvl)]})" for lvl in q.levels()]
|
|
62
|
+
logits, n_in = self.score_options(
|
|
63
|
+
request.model,
|
|
64
|
+
self.prompt_render(request.state, q.instructions, labels),
|
|
65
|
+
labels
|
|
66
|
+
)
|
|
67
|
+
answers[qid] = build_score_answer(q, logits, temperature)
|
|
68
|
+
elif isinstance(q, Noul):
|
|
69
|
+
logits, n_in = self.score_options(
|
|
70
|
+
request.model,
|
|
71
|
+
self.prompt_render(request.state, q.instructions, NOUL_LABELS),
|
|
72
|
+
NOUL_LABELS
|
|
73
|
+
)
|
|
74
|
+
answers[qid] = build_noul_answer(logits[0], logits[1], temperature)
|
|
75
|
+
else: # pragma: no cover
|
|
76
|
+
raise TypeError(f"Unknown question type: {type(q)!r}")
|
|
77
|
+
usage.input_tokens += n_in
|
|
78
|
+
usage.output_tokens += len(labels) if not isinstance(q, Noul) else 2
|
|
79
|
+
latency_ms = (time.perf_counter() - t0) * 1000
|
|
80
|
+
return SystemOneResponse(
|
|
81
|
+
model=request.model, answers=answers, usage=usage, latency_ms=round(latency_ms, 2)
|
|
82
|
+
)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Deterministic mock backend.
|
|
2
|
+
|
|
3
|
+
Useful for tests, demos and CI: no model download, sub-millisecond latency, and
|
|
4
|
+
stable outputs derived from a hash of (prompt, label). It exists so the REPL, the
|
|
5
|
+
server and the SDK-compat tests can run anywhere.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
|
|
12
|
+
from decision_ui.backends.base import Backend
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class MockBackend(Backend):
|
|
16
|
+
@property
|
|
17
|
+
def available_models(self) -> list[str]:
|
|
18
|
+
return ["mock-model"]
|
|
19
|
+
|
|
20
|
+
def score_options(self, model: str, prompt: str, labels: list[str]) -> tuple[list[float], int]:
|
|
21
|
+
logits: list[float] = []
|
|
22
|
+
for label in labels:
|
|
23
|
+
h = hashlib.sha256((prompt + "\x00" + label).encode("utf-8")).digest()
|
|
24
|
+
# map first 4 bytes to a logit in roughly [-3, 3]
|
|
25
|
+
v = int.from_bytes(h[:4], "big") / 0xFFFFFFFF
|
|
26
|
+
logits.append((v - 0.5) * 6.0)
|
|
27
|
+
# crude token estimate: ~4 chars per token
|
|
28
|
+
return logits, max(1, len(prompt) // 4)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""vLLM backend.
|
|
2
|
+
|
|
3
|
+
One forward pass per (state, question). Logits are read at the last position and
|
|
4
|
+
only the first token of each label is compared, then softmaxed across labels so
|
|
5
|
+
nothing outside the option set can win.
|
|
6
|
+
|
|
7
|
+
Phase 1 will add: shared-state prefill with KV-cache reuse across questions, and
|
|
8
|
+
multi-token label scoring (sum of log-probs) for labels that are not single tokens.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from openai import OpenAI
|
|
14
|
+
from decision_ui.backends.base import Backend
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class VLLMBackend(Backend):
|
|
18
|
+
def __init__(self, api_key: str | None = None, base_url: str | None = None, **kwargs):
|
|
19
|
+
super().__init__(**kwargs)
|
|
20
|
+
self.api_key = api_key
|
|
21
|
+
self.base_url = base_url
|
|
22
|
+
self._model_client = OpenAI(api_key=self.api_key, base_url=self.base_url)
|
|
23
|
+
self._tokenizer_client = OpenAI(api_key=self.api_key, base_url=self.base_url.replace("/v1", ""))
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def available_models(self) -> list[str]:
|
|
27
|
+
return [model.id for model in self._model_client.models.list().data]
|
|
28
|
+
|
|
29
|
+
def _tokenize(self, model: str, text: str) -> list[int]:
|
|
30
|
+
enc = self._tokenizer_client.post(
|
|
31
|
+
"/tokenize",
|
|
32
|
+
cast_to=dict,
|
|
33
|
+
body={
|
|
34
|
+
"model": model,
|
|
35
|
+
"prompt": text,
|
|
36
|
+
},
|
|
37
|
+
)
|
|
38
|
+
return enc["tokens"]
|
|
39
|
+
|
|
40
|
+
def _detokenize(self, model: str, token_ids: list[int]) -> str:
|
|
41
|
+
dec = self._tokenizer_client.post(
|
|
42
|
+
"/detokenize",
|
|
43
|
+
cast_to=dict,
|
|
44
|
+
body={
|
|
45
|
+
"model": model,
|
|
46
|
+
"tokens": token_ids,
|
|
47
|
+
},
|
|
48
|
+
)
|
|
49
|
+
return dec["prompt"]
|
|
50
|
+
|
|
51
|
+
def _first_token(self, model: str, label: str) -> str:
|
|
52
|
+
ids = self._tokenize(model, label)
|
|
53
|
+
return self._detokenize(model, [ids[0]])
|
|
54
|
+
|
|
55
|
+
def score_options(self, model: str, prompt: str, labels: list[str]) -> tuple[list[float], int]:
|
|
56
|
+
token_ids = self._tokenize(model, prompt)
|
|
57
|
+
|
|
58
|
+
response = self._model_client.chat.completions.create(
|
|
59
|
+
model=model,
|
|
60
|
+
messages=[{"role": "user", "content": prompt}],
|
|
61
|
+
logprobs=True,
|
|
62
|
+
top_logprobs=20,
|
|
63
|
+
extra_body={
|
|
64
|
+
"chat_template_kwargs": {
|
|
65
|
+
"enable_thinking": False
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
)
|
|
69
|
+
pred_tokens = {top_logprob.token: top_logprob.logprob for top_logprob in response.choices[0].logprobs.content[0].top_logprobs}
|
|
70
|
+
cand_tokens = [self._first_token(model, label) for label in labels]
|
|
71
|
+
logits = [pred_tokens.get(token, float("-inf")) for token in cand_tokens]
|
|
72
|
+
return logits, len(token_ids)
|
|
File without changes
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
import uvicorn
|
|
3
|
+
from typing import Annotated
|
|
4
|
+
from decision_ui.server import create_app
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
serve_commands = typer.Typer()
|
|
8
|
+
|
|
9
|
+
@serve_commands.command("serve")
|
|
10
|
+
def serve_command(
|
|
11
|
+
host: Annotated[str, typer.Option("--host", help="The host to serve on")] = "localhost",
|
|
12
|
+
port: Annotated[int, typer.Option("--port", help="The port to serve on")] = 8000,
|
|
13
|
+
timeout: Annotated[int, typer.Option("--timeout", help="The timeout for root.run() in seconds")] = 300,
|
|
14
|
+
backend: Annotated[str, typer.Option("--backend", help="The backend to use for the RØØT server")] = "vllm",
|
|
15
|
+
base_url: Annotated[str, typer.Option("--base_url", help="The base URL for the backend")] = "http://localhost:8000",
|
|
16
|
+
api_key: Annotated[str, typer.Option("--api_key", help="The API key for the backend")] = "EMPTY",
|
|
17
|
+
mock: Annotated[bool, typer.Option("--mock", help="Enable mock mode for the backend")] = False,
|
|
18
|
+
):
|
|
19
|
+
# Create the FastAPI app and run it
|
|
20
|
+
app = create_app(
|
|
21
|
+
config={
|
|
22
|
+
"backend": backend if not mock else "mock",
|
|
23
|
+
"base_url": base_url if base_url.endswith("/v1") else base_url + "/v1",
|
|
24
|
+
"api_key": api_key,
|
|
25
|
+
"timeout": timeout,
|
|
26
|
+
}
|
|
27
|
+
)
|
|
28
|
+
uvicorn.run(app, host=host, port=port, workers=1)
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""System One primitives: Choice, Score, Noul.
|
|
2
|
+
|
|
3
|
+
These models mirror the request / response shape of TypeSafe's ``POST /v1/systemone``
|
|
4
|
+
endpoint so that the official SDKs work against OpenJev by changing only the base URL.
|
|
5
|
+
|
|
6
|
+
OpenJev reproduces the *interface pattern*, not TypeSafe's undisclosed model or training.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any, Literal, Union
|
|
12
|
+
|
|
13
|
+
from pydantic import BaseModel, Field, model_validator
|
|
14
|
+
|
|
15
|
+
# --------------------------------------------------------------------------------------
|
|
16
|
+
# Questions (request side)
|
|
17
|
+
# --------------------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Choice(BaseModel):
|
|
21
|
+
"""Pick exactly one option from a runtime-defined set (2..255 options)."""
|
|
22
|
+
|
|
23
|
+
type: Literal["choice"] = "choice"
|
|
24
|
+
instructions: str
|
|
25
|
+
options: list[str] | None = None
|
|
26
|
+
criteria: dict[str, str | None] | None = None
|
|
27
|
+
|
|
28
|
+
@model_validator(mode="after")
|
|
29
|
+
def _check_options(self) -> Choice:
|
|
30
|
+
keys = self.option_keys()
|
|
31
|
+
if len(keys) < 2:
|
|
32
|
+
raise ValueError("Choice needs at least 2 options (use `options` or `criteria`).")
|
|
33
|
+
if len(keys) > 255:
|
|
34
|
+
raise ValueError("Choice supports at most 255 options.")
|
|
35
|
+
return self
|
|
36
|
+
|
|
37
|
+
def option_keys(self) -> list[str]:
|
|
38
|
+
if self.criteria:
|
|
39
|
+
return list(self.criteria.keys())
|
|
40
|
+
return list(self.options or [])
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Score(BaseModel):
|
|
44
|
+
"""Grade the state on an ordered scale (2..10 levels). Returns a probability-weighted score.
|
|
45
|
+
|
|
46
|
+
Accepts either the official request form ``criteria=["level 0 desc", "level 1 desc", ...]``
|
|
47
|
+
or the response-style ``legend={"0": "...", "1": "..."}``. ``legend`` is always populated.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
type: Literal["score"] = "score"
|
|
51
|
+
instructions: str
|
|
52
|
+
criteria: list[str] | None = None
|
|
53
|
+
legend: dict[str, str] | None = None
|
|
54
|
+
|
|
55
|
+
@model_validator(mode="after")
|
|
56
|
+
def _normalise(self) -> "Score":
|
|
57
|
+
if self.legend is None and self.criteria is not None:
|
|
58
|
+
self.legend = {str(i): desc for i, desc in enumerate(self.criteria)}
|
|
59
|
+
if self.legend is None:
|
|
60
|
+
raise ValueError("Score needs `criteria` (list) or `legend` (dict).")
|
|
61
|
+
if not (2 <= len(self.legend) <= 10):
|
|
62
|
+
raise ValueError("Score needs between 2 and 10 levels.")
|
|
63
|
+
for k in self.legend:
|
|
64
|
+
int(k) # raises if not numeric
|
|
65
|
+
return self
|
|
66
|
+
|
|
67
|
+
def levels(self) -> list[int]:
|
|
68
|
+
return sorted(int(k) for k in self.legend)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class Noul(BaseModel):
|
|
72
|
+
"""A yes/no judgement. Returns P(true) in [0, 1]."""
|
|
73
|
+
|
|
74
|
+
type: Literal["noul"] = "noul"
|
|
75
|
+
instructions: str
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
Question = Union[Choice, Score, Noul]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class SystemOneRequest(BaseModel):
|
|
82
|
+
"""Body of ``POST /v1/systemone``."""
|
|
83
|
+
|
|
84
|
+
state: Any
|
|
85
|
+
questions: dict[str, Question]
|
|
86
|
+
model: str = "openjev-latest"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# --------------------------------------------------------------------------------------
|
|
90
|
+
# Answers (response side)
|
|
91
|
+
# --------------------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class ChoiceAnswer(BaseModel):
|
|
95
|
+
type: Literal["choice"] = "choice"
|
|
96
|
+
choice: str
|
|
97
|
+
probabilities: dict[str, float]
|
|
98
|
+
confidence: float
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class ScoreAnswer(BaseModel):
|
|
102
|
+
type: Literal["score"] = "score"
|
|
103
|
+
score: float
|
|
104
|
+
legend: dict[str, str]
|
|
105
|
+
probabilities: dict[str, float]
|
|
106
|
+
confidence: float
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class NoulAnswer(BaseModel):
|
|
110
|
+
type: Literal["noul"] = "noul"
|
|
111
|
+
noul: float
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
Answer = Union[ChoiceAnswer, ScoreAnswer, NoulAnswer]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class Usage(BaseModel):
|
|
118
|
+
input_tokens: int = 0
|
|
119
|
+
output_tokens: int = 0
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class SystemOneResponse(BaseModel):
|
|
123
|
+
model: str
|
|
124
|
+
answers: dict[str, Answer]
|
|
125
|
+
usage: Usage = Field(default_factory=Usage)
|
|
126
|
+
latency_ms: float | None = None
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import math
|
|
2
|
+
from decision_ui.core.data_types import Choice, ChoiceAnswer, Score, ScoreAnswer, NoulAnswer
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def softmax(logits: list[float], temperature: float = 1.0) -> list[float]:
|
|
6
|
+
if temperature <= 0:
|
|
7
|
+
raise ValueError("temperature must be > 0")
|
|
8
|
+
scaled = [x / temperature for x in logits]
|
|
9
|
+
m = max(scaled)
|
|
10
|
+
if m == -math.inf:
|
|
11
|
+
return [0.0 for _ in logits]
|
|
12
|
+
|
|
13
|
+
exps = [math.exp(x - m) for x in scaled]
|
|
14
|
+
total = sum(exps)
|
|
15
|
+
return [e / total for e in exps]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def confidence_from_probs(probs: list[float]) -> float:
|
|
19
|
+
"""Confidence in [0, 1] as 1 - normalized entropy.
|
|
20
|
+
|
|
21
|
+
TypeSafe does not publish its confidence formula. This is an interpretable
|
|
22
|
+
approximation: 1.0 when all mass is on one option, 0.0 when uniform.
|
|
23
|
+
"""
|
|
24
|
+
n = len(probs)
|
|
25
|
+
if n <= 1:
|
|
26
|
+
return 1.0
|
|
27
|
+
ent = -sum(p * math.log(p) for p in probs if p > 0)
|
|
28
|
+
return max(0.0, min(1.0, 1.0 - ent / math.log(n)))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def build_choice_answer(
|
|
32
|
+
question: Choice, option_logits: list[float], temperature: float = 1.0
|
|
33
|
+
) -> ChoiceAnswer:
|
|
34
|
+
keys = question.option_keys()
|
|
35
|
+
probs = softmax(option_logits, temperature)
|
|
36
|
+
dist = {k: round(p, 4) for k, p in zip(keys, probs)}
|
|
37
|
+
best = keys[max(range(len(keys)), key=lambda i: probs[i])]
|
|
38
|
+
return ChoiceAnswer(choice=best, probabilities=dist, confidence=round(confidence_from_probs(probs), 4))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def build_score_answer(question: Score, level_logits: list[float], temperature: float = 1.0) -> ScoreAnswer:
|
|
42
|
+
levels = question.levels()
|
|
43
|
+
probs = softmax(level_logits, temperature)
|
|
44
|
+
expectation = sum(l * p for l, p in zip(levels, probs))
|
|
45
|
+
dist = {str(l): round(p, 4) for l, p in zip(levels, probs)}
|
|
46
|
+
return ScoreAnswer(
|
|
47
|
+
score=round(expectation, 4),
|
|
48
|
+
legend=question.legend,
|
|
49
|
+
probabilities=dist,
|
|
50
|
+
confidence=round(confidence_from_probs(probs), 4),
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def build_noul_answer(yes_logit: float, no_logit: float, temperature: float = 1.0) -> NoulAnswer:
|
|
55
|
+
p_yes, _ = softmax([yes_logit, no_logit], temperature)
|
|
56
|
+
return NoulAnswer(noul=round(p_yes, 4))
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
from .default import render_prompt as default_render_prompt
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def get_prompt_render(state: Any, instructions: str, labels: list[str], name: str = "default") -> str:
|
|
8
|
+
if name == "default":
|
|
9
|
+
return default_render_prompt(state, instructions, labels)
|
|
10
|
+
raise ValueError(f"Unknown prompt name: {name}")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
__all__ = ["get_prompt_render"]
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def render_state(state: Any) -> str:
|
|
6
|
+
if isinstance(state, str):
|
|
7
|
+
return state
|
|
8
|
+
return json.dumps(state, ensure_ascii=False, indent=2)
|
|
9
|
+
|
|
10
|
+
def render_prompt(state: Any, instructions: str, labels: list[str]) -> str:
|
|
11
|
+
"""Chat-style prompt whose next token is expected to be one of ``labels``."""
|
|
12
|
+
options = "\n".join(f"- {label}" for label in labels)
|
|
13
|
+
return (
|
|
14
|
+
"You are a decision engine. Read the state, then answer the question by replying "
|
|
15
|
+
"with exactly one of the allowed labels and nothing else.\n\n"
|
|
16
|
+
f"### State\n{render_state(state)}\n\n"
|
|
17
|
+
f"### Question\n{instructions}\n\n"
|
|
18
|
+
f"### Allowed labels\n{options}\n"
|
|
19
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .server import create_app
|
|
File without changes
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from fastapi import APIRouter, Request
|
|
2
|
+
from fastapi.responses import JSONResponse
|
|
3
|
+
from decision_ui import Choice, Noul, Score, SystemOneRequest, SystemOneResponse
|
|
4
|
+
|
|
5
|
+
router = APIRouter()
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@router.post("/decision")
|
|
9
|
+
async def decision(request: Request):
|
|
10
|
+
timeout = request.app.state.timeout
|
|
11
|
+
backend = request.app.state.backend
|
|
12
|
+
|
|
13
|
+
body: dict = await request.json()
|
|
14
|
+
print(body)
|
|
15
|
+
|
|
16
|
+
questions = {}
|
|
17
|
+
for key, value in body["questions"].items():
|
|
18
|
+
if value["type"] == "choice":
|
|
19
|
+
question = Choice(instructions=value["instructions"], options=value.get("options"), criteria=value.get("criteria"))
|
|
20
|
+
elif value["type"] == "noul":
|
|
21
|
+
question = Noul(instructions=value["instructions"])
|
|
22
|
+
elif value["type"] == "score":
|
|
23
|
+
question = Score(instructions=value["instructions"], criteria=value.get("criteria"), legend=value.get("legend"))
|
|
24
|
+
questions[key] = question
|
|
25
|
+
|
|
26
|
+
response: SystemOneResponse = backend.decide(SystemOneRequest(
|
|
27
|
+
model=body["model"],
|
|
28
|
+
state=body["state"],
|
|
29
|
+
questions=questions,
|
|
30
|
+
))
|
|
31
|
+
|
|
32
|
+
return JSONResponse(response.model_dump())
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from fastapi import APIRouter, Request
|
|
2
|
+
from fastapi.responses import JSONResponse
|
|
3
|
+
from decision_ui import Choice, Noul, Score, SystemOneRequest, SystemOneResponse
|
|
4
|
+
|
|
5
|
+
router = APIRouter()
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@router.post("/decisions")
|
|
9
|
+
async def decisions(request: Request):
|
|
10
|
+
timeout = request.app.state.timeout
|
|
11
|
+
backend = request.app.state.backend
|
|
12
|
+
|
|
13
|
+
body: dict = await request.json()
|
|
14
|
+
print(body)
|
|
15
|
+
|
|
16
|
+
questions = {}
|
|
17
|
+
questions_types = {}
|
|
18
|
+
for value in body["questions"]:
|
|
19
|
+
key = value["name"]
|
|
20
|
+
if value["type"] == "choice":
|
|
21
|
+
criteria = {choice["value"]: choice["description"] for choice in value["choices"]}
|
|
22
|
+
question = Choice(instructions=value["instructions"], criteria=criteria)
|
|
23
|
+
questions_types[key] = "choice"
|
|
24
|
+
elif value["type"] == "predicate":
|
|
25
|
+
question = Noul(instructions=value["instructions"])
|
|
26
|
+
questions_types[key] = "predicate"
|
|
27
|
+
elif value["type"] == "score":
|
|
28
|
+
legend = {str(i): level["label"] for i, level in enumerate(value["levels"])}
|
|
29
|
+
question = Score(instructions=value["instructions"], legend=legend)
|
|
30
|
+
questions_types[key] = "score"
|
|
31
|
+
questions[key] = question
|
|
32
|
+
|
|
33
|
+
response: SystemOneResponse = backend.decide(SystemOneRequest(
|
|
34
|
+
model=body["model"],
|
|
35
|
+
state=body["input"],
|
|
36
|
+
questions=questions,
|
|
37
|
+
))
|
|
38
|
+
|
|
39
|
+
def format_answer(answer, name: str):
|
|
40
|
+
if answer.type == "choice":
|
|
41
|
+
return {
|
|
42
|
+
"type": "choice",
|
|
43
|
+
"name": name,
|
|
44
|
+
"choice": answer.choice,
|
|
45
|
+
"probabilities": [{"value": value, "probability": prob} for value, prob in answer.probabilities.items()],
|
|
46
|
+
"confidence": answer.confidence
|
|
47
|
+
}
|
|
48
|
+
elif answer.type == "noul":
|
|
49
|
+
return {
|
|
50
|
+
"type": "predicate",
|
|
51
|
+
"name": name,
|
|
52
|
+
"probability": answer.noul
|
|
53
|
+
}
|
|
54
|
+
elif answer.type == "score":
|
|
55
|
+
return {
|
|
56
|
+
"type": "score",
|
|
57
|
+
"name": name,
|
|
58
|
+
"score": answer.score,
|
|
59
|
+
"probabilities": [{"value": value, "label": answer.legend[value], "probability": prob} for value, prob in answer.probabilities.items()],
|
|
60
|
+
"confidence": answer.confidence
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return JSONResponse({
|
|
64
|
+
"model": body["model"],
|
|
65
|
+
"answers": [format_answer(response.answers[value["name"]], value["name"]) for value in body["questions"]],
|
|
66
|
+
"usage": response.usage.model_dump()
|
|
67
|
+
})
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from fastapi import APIRouter, Request
|
|
2
|
+
from fastapi.responses import JSONResponse
|
|
3
|
+
from decision_ui import Choice, Noul, Score, SystemOneRequest, SystemOneResponse
|
|
4
|
+
|
|
5
|
+
router = APIRouter()
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@router.post("/systemone")
|
|
9
|
+
async def systemone(request: Request):
|
|
10
|
+
timeout = request.app.state.timeout
|
|
11
|
+
backend = request.app.state.backend
|
|
12
|
+
|
|
13
|
+
body: dict = await request.json()
|
|
14
|
+
print(body)
|
|
15
|
+
|
|
16
|
+
questions = {}
|
|
17
|
+
for key, value in body["questions"].items():
|
|
18
|
+
if value["type"] == "choice":
|
|
19
|
+
question = Choice(instructions=value["instructions"], options=value.get("options"), criteria=value.get("criteria"))
|
|
20
|
+
elif value["type"] == "noul":
|
|
21
|
+
question = Noul(instructions=value["instructions"])
|
|
22
|
+
elif value["type"] == "score":
|
|
23
|
+
question = Score(instructions=value["instructions"], criteria=value.get("criteria"), legend=value.get("legend"))
|
|
24
|
+
questions[key] = question
|
|
25
|
+
|
|
26
|
+
response: SystemOneResponse = backend.decide(SystemOneRequest(
|
|
27
|
+
model=body["model"],
|
|
28
|
+
state=body["state"],
|
|
29
|
+
questions=questions,
|
|
30
|
+
))
|
|
31
|
+
|
|
32
|
+
return JSONResponse(response.model_dump())
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from fastapi import FastAPI
|
|
2
|
+
from decision_ui.backends import get_backend
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def create_app(config: dict) -> FastAPI:
|
|
6
|
+
app = FastAPI()
|
|
7
|
+
print(f"Creating FastAPI app with configuration: {config}")
|
|
8
|
+
app.state.timeout = config.get("timeout")
|
|
9
|
+
app.state.backend = get_backend(
|
|
10
|
+
config["backend"],
|
|
11
|
+
base_url=config.get("base_url"),
|
|
12
|
+
api_key=config.get("api_key"),
|
|
13
|
+
)
|
|
14
|
+
print(f"Available models: {app.state.backend.available_models}")
|
|
15
|
+
|
|
16
|
+
# ------------------------------------------------------------------
|
|
17
|
+
# Inference routers (hooks fire on these)
|
|
18
|
+
# Registered under both "" and "/v1" so the proxy works regardless
|
|
19
|
+
# of whether the client sets base_url="http://host:port" or
|
|
20
|
+
# base_url="http://host:port/v1".
|
|
21
|
+
# ------------------------------------------------------------------
|
|
22
|
+
from .routes.systemone import router as systemone_router
|
|
23
|
+
from .routes.decisions import router as decisions_router
|
|
24
|
+
from .routes.decision import router as decision_router
|
|
25
|
+
|
|
26
|
+
app.include_router(systemone_router)
|
|
27
|
+
app.include_router(decisions_router)
|
|
28
|
+
app.include_router(decision_router)
|
|
29
|
+
|
|
30
|
+
app.include_router(systemone_router, prefix="/v1")
|
|
31
|
+
app.include_router(decisions_router, prefix="/v1")
|
|
32
|
+
app.include_router(decision_router, prefix="/alpha")
|
|
33
|
+
|
|
34
|
+
return app
|