OpenDecision 0.1.1__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.
- opendecision/__init__.py +24 -0
- opendecision/__main__.py +4 -0
- opendecision/api/__init__.py +0 -0
- opendecision/api/app.py +231 -0
- opendecision/api/schemas.py +107 -0
- opendecision/cli.py +55 -0
- opendecision/documents.py +449 -0
- opendecision/engine.py +615 -0
- opendecision/evidence.py +422 -0
- opendecision/py.typed +0 -0
- opendecision/rules.py +107 -0
- opendecision-0.1.1.dist-info/METADATA +185 -0
- opendecision-0.1.1.dist-info/RECORD +16 -0
- opendecision-0.1.1.dist-info/WHEEL +4 -0
- opendecision-0.1.1.dist-info/entry_points.txt +2 -0
- opendecision-0.1.1.dist-info/licenses/LICENSE +201 -0
opendecision/__init__.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
2
|
+
from typing import TYPE_CHECKING, Any
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
if TYPE_CHECKING:
|
|
6
|
+
from opendecision.engine import OpenDecisionEngine
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
__version__ = version("OpenDecision")
|
|
11
|
+
except PackageNotFoundError:
|
|
12
|
+
__version__ = "0+unknown"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
__all__ = ["OpenDecisionEngine", "__version__"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def __getattr__(name: str) -> Any:
|
|
19
|
+
if name == "OpenDecisionEngine":
|
|
20
|
+
from opendecision.engine import OpenDecisionEngine
|
|
21
|
+
|
|
22
|
+
return OpenDecisionEngine
|
|
23
|
+
|
|
24
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
opendecision/__main__.py
ADDED
|
File without changes
|
opendecision/api/app.py
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
from contextlib import asynccontextmanager
|
|
4
|
+
|
|
5
|
+
from fastapi import FastAPI, HTTPException, Request
|
|
6
|
+
|
|
7
|
+
from opendecision import __version__
|
|
8
|
+
from opendecision.api.schemas import (
|
|
9
|
+
ChoiceQuestion,
|
|
10
|
+
DocumentDecisionRequest,
|
|
11
|
+
DocumentDecisionResponse,
|
|
12
|
+
NoulQuestion,
|
|
13
|
+
RelationQuestion,
|
|
14
|
+
ScoreQuestion,
|
|
15
|
+
SystemOneRequest,
|
|
16
|
+
SystemOneResponse,
|
|
17
|
+
Usage,
|
|
18
|
+
)
|
|
19
|
+
from opendecision.engine import (
|
|
20
|
+
DEFAULT_MODEL,
|
|
21
|
+
OpenDecisionEngine,
|
|
22
|
+
)
|
|
23
|
+
from opendecision.documents import DocumentDecisionService
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
API_MODEL_NAME = "modernbert-large-zeroshot-v2"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@asynccontextmanager
|
|
30
|
+
async def lifespan(app: FastAPI):
|
|
31
|
+
print("Starting OpenDecision...")
|
|
32
|
+
|
|
33
|
+
# Load once when the server starts.
|
|
34
|
+
model = os.environ.get("OPENDECISION_MODEL", DEFAULT_MODEL)
|
|
35
|
+
app.state.engine = OpenDecisionEngine(
|
|
36
|
+
model=model,
|
|
37
|
+
)
|
|
38
|
+
app.state.model_name = model
|
|
39
|
+
|
|
40
|
+
print("OpenDecision ready.")
|
|
41
|
+
|
|
42
|
+
yield
|
|
43
|
+
|
|
44
|
+
print("Stopping OpenDecision...")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
app = FastAPI(
|
|
48
|
+
title="OpenDecision",
|
|
49
|
+
description="Open-source semantic decision engine.",
|
|
50
|
+
version=__version__,
|
|
51
|
+
lifespan=lifespan,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@app.get("/health")
|
|
56
|
+
def health():
|
|
57
|
+
return {
|
|
58
|
+
"status": "ok",
|
|
59
|
+
"service": "OpenDecision",
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def estimate_input_tokens(
|
|
64
|
+
engine: OpenDecisionEngine,
|
|
65
|
+
payload: SystemOneRequest,
|
|
66
|
+
) -> int:
|
|
67
|
+
"""
|
|
68
|
+
Approximate logical API input size.
|
|
69
|
+
|
|
70
|
+
This is NOT currently intended to represent actual transformer
|
|
71
|
+
compute, because zero-shot classification may evaluate the same
|
|
72
|
+
state against multiple candidate hypotheses.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
serialized = json.dumps(
|
|
76
|
+
payload.model_dump(),
|
|
77
|
+
ensure_ascii=False,
|
|
78
|
+
sort_keys=True,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
tokens = engine.classifier.tokenizer(
|
|
82
|
+
serialized,
|
|
83
|
+
add_special_tokens=False,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
return len(tokens["input_ids"])
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@app.post(
|
|
90
|
+
"/v1/systemone",
|
|
91
|
+
response_model=SystemOneResponse,
|
|
92
|
+
)
|
|
93
|
+
def system_one(
|
|
94
|
+
payload: SystemOneRequest,
|
|
95
|
+
request: Request,
|
|
96
|
+
):
|
|
97
|
+
engine: OpenDecisionEngine = request.app.state.engine
|
|
98
|
+
|
|
99
|
+
answers = {}
|
|
100
|
+
|
|
101
|
+
for question_name, question in payload.questions.items():
|
|
102
|
+
|
|
103
|
+
if isinstance(question, ChoiceQuestion):
|
|
104
|
+
|
|
105
|
+
result = engine.choice(
|
|
106
|
+
state=payload.state,
|
|
107
|
+
instructions=question.instructions,
|
|
108
|
+
criteria=question.criteria,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
elif isinstance(question, NoulQuestion):
|
|
112
|
+
|
|
113
|
+
criteria = None
|
|
114
|
+
|
|
115
|
+
if question.criteria is not None:
|
|
116
|
+
criteria = question.criteria.model_dump()
|
|
117
|
+
|
|
118
|
+
result = engine.noul(
|
|
119
|
+
state=payload.state,
|
|
120
|
+
instructions=question.instructions,
|
|
121
|
+
criteria=criteria,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
elif isinstance(question, RelationQuestion):
|
|
125
|
+
|
|
126
|
+
result = engine.relation(
|
|
127
|
+
state=payload.state,
|
|
128
|
+
proposition=question.proposition,
|
|
129
|
+
contradiction=question.contradiction,
|
|
130
|
+
threshold=question.threshold,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
elif isinstance(question, ScoreQuestion):
|
|
134
|
+
|
|
135
|
+
result = engine.score(
|
|
136
|
+
state=payload.state,
|
|
137
|
+
instructions=question.instructions,
|
|
138
|
+
criteria=question.criteria,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
else:
|
|
142
|
+
raise HTTPException(
|
|
143
|
+
status_code=422,
|
|
144
|
+
detail=f"Unsupported question type for {question_name}",
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
answers[question_name] = result
|
|
148
|
+
|
|
149
|
+
return SystemOneResponse(
|
|
150
|
+
model=getattr(request.app.state, "model_name", API_MODEL_NAME),
|
|
151
|
+
answers=answers,
|
|
152
|
+
usage=Usage(
|
|
153
|
+
input_tokens=estimate_input_tokens(engine, payload),
|
|
154
|
+
output_tokens=0,
|
|
155
|
+
),
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@app.post(
|
|
160
|
+
"/v1/documents/decide",
|
|
161
|
+
response_model=DocumentDecisionResponse,
|
|
162
|
+
)
|
|
163
|
+
def decide_document(
|
|
164
|
+
payload: DocumentDecisionRequest,
|
|
165
|
+
request: Request,
|
|
166
|
+
):
|
|
167
|
+
engine: OpenDecisionEngine = request.app.state.engine
|
|
168
|
+
service = DocumentDecisionService(
|
|
169
|
+
engine,
|
|
170
|
+
top_k=payload.top_k,
|
|
171
|
+
chunk_tokens=payload.chunk_tokens,
|
|
172
|
+
)
|
|
173
|
+
try:
|
|
174
|
+
chunks = service.chunks(payload.document)
|
|
175
|
+
except ValueError as error:
|
|
176
|
+
raise HTTPException(status_code=422, detail=str(error)) from error
|
|
177
|
+
answers = {}
|
|
178
|
+
|
|
179
|
+
for question_name, question in payload.questions.items():
|
|
180
|
+
if isinstance(question, ChoiceQuestion):
|
|
181
|
+
result = service.choice(
|
|
182
|
+
chunks=chunks,
|
|
183
|
+
instructions=question.instructions,
|
|
184
|
+
criteria=question.criteria,
|
|
185
|
+
)
|
|
186
|
+
elif isinstance(question, NoulQuestion):
|
|
187
|
+
criteria = (
|
|
188
|
+
question.criteria.model_dump()
|
|
189
|
+
if question.criteria is not None
|
|
190
|
+
else None
|
|
191
|
+
)
|
|
192
|
+
result = service.noul(
|
|
193
|
+
chunks=chunks,
|
|
194
|
+
instructions=question.instructions,
|
|
195
|
+
criteria=criteria,
|
|
196
|
+
mode=payload.noul_mode,
|
|
197
|
+
)
|
|
198
|
+
elif isinstance(question, RelationQuestion):
|
|
199
|
+
result = service.relation(
|
|
200
|
+
chunks=chunks,
|
|
201
|
+
proposition=question.proposition,
|
|
202
|
+
contradiction=question.contradiction,
|
|
203
|
+
threshold=question.threshold,
|
|
204
|
+
)
|
|
205
|
+
elif isinstance(question, ScoreQuestion):
|
|
206
|
+
result = service.score(
|
|
207
|
+
chunks=chunks,
|
|
208
|
+
instructions=question.instructions,
|
|
209
|
+
criteria=question.criteria,
|
|
210
|
+
)
|
|
211
|
+
else:
|
|
212
|
+
raise HTTPException(
|
|
213
|
+
status_code=422,
|
|
214
|
+
detail=f"Unsupported question type for {question_name}",
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
answers[question_name] = result
|
|
218
|
+
|
|
219
|
+
token_payload = SystemOneRequest(
|
|
220
|
+
state=payload.document,
|
|
221
|
+
questions=payload.questions,
|
|
222
|
+
)
|
|
223
|
+
return DocumentDecisionResponse(
|
|
224
|
+
model=getattr(request.app.state, "model_name", API_MODEL_NAME),
|
|
225
|
+
chunks=len(chunks),
|
|
226
|
+
answers=answers,
|
|
227
|
+
usage=Usage(
|
|
228
|
+
input_tokens=estimate_input_tokens(engine, token_payload),
|
|
229
|
+
output_tokens=0,
|
|
230
|
+
),
|
|
231
|
+
)
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
from typing import Annotated, Any, Literal
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, Field, field_validator
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ChoiceQuestion(BaseModel):
|
|
7
|
+
type: Literal["choice"]
|
|
8
|
+
instructions: str
|
|
9
|
+
criteria: dict[str, str | None]
|
|
10
|
+
|
|
11
|
+
@field_validator("criteria")
|
|
12
|
+
@classmethod
|
|
13
|
+
def validate_criteria(cls, value):
|
|
14
|
+
if len(value) < 2:
|
|
15
|
+
raise ValueError("Choice requires at least two criteria.")
|
|
16
|
+
return value
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class NoulCriteria(BaseModel):
|
|
20
|
+
true: str
|
|
21
|
+
false: str
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class NoulQuestion(BaseModel):
|
|
25
|
+
type: Literal["noul"]
|
|
26
|
+
instructions: str
|
|
27
|
+
criteria: NoulCriteria | None = None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class RelationQuestion(BaseModel):
|
|
31
|
+
type: Literal["relation"]
|
|
32
|
+
proposition: str
|
|
33
|
+
contradiction: str
|
|
34
|
+
threshold: float = Field(default=0.5, gt=0.0, lt=1.0)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ScoreQuestion(BaseModel):
|
|
38
|
+
type: Literal["score"]
|
|
39
|
+
instructions: str
|
|
40
|
+
criteria: list[str]
|
|
41
|
+
|
|
42
|
+
@field_validator("criteria")
|
|
43
|
+
@classmethod
|
|
44
|
+
def validate_criteria(cls, value):
|
|
45
|
+
if len(value) < 2:
|
|
46
|
+
raise ValueError("Score requires at least two levels.")
|
|
47
|
+
|
|
48
|
+
if len(set(value)) != len(value):
|
|
49
|
+
raise ValueError("Score criteria must be unique.")
|
|
50
|
+
|
|
51
|
+
return value
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
Question = Annotated[
|
|
55
|
+
ChoiceQuestion | NoulQuestion | RelationQuestion | ScoreQuestion,
|
|
56
|
+
Field(discriminator="type"),
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class SystemOneRequest(BaseModel):
|
|
61
|
+
state: Any
|
|
62
|
+
|
|
63
|
+
# Optional for now.
|
|
64
|
+
# This will help later when we point the official SDK at OpenDecision.
|
|
65
|
+
model: str | None = None
|
|
66
|
+
|
|
67
|
+
questions: dict[str, Question]
|
|
68
|
+
|
|
69
|
+
@field_validator("questions")
|
|
70
|
+
@classmethod
|
|
71
|
+
def validate_questions(cls, value):
|
|
72
|
+
if not value:
|
|
73
|
+
raise ValueError("At least one question is required.")
|
|
74
|
+
return value
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class Usage(BaseModel):
|
|
78
|
+
input_tokens: int
|
|
79
|
+
output_tokens: int
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class SystemOneResponse(BaseModel):
|
|
83
|
+
model: str
|
|
84
|
+
answers: dict[str, dict[str, Any]]
|
|
85
|
+
usage: Usage
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class DocumentDecisionRequest(BaseModel):
|
|
89
|
+
document: Any
|
|
90
|
+
questions: dict[str, Question]
|
|
91
|
+
noul_mode: Literal["binary", "three_way", "both"] = "both"
|
|
92
|
+
top_k: int = Field(default=4, ge=1, le=20)
|
|
93
|
+
chunk_tokens: int = Field(default=384, ge=32, le=4096)
|
|
94
|
+
|
|
95
|
+
@field_validator("questions")
|
|
96
|
+
@classmethod
|
|
97
|
+
def validate_document_questions(cls, value):
|
|
98
|
+
if not value:
|
|
99
|
+
raise ValueError("At least one question is required.")
|
|
100
|
+
return value
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class DocumentDecisionResponse(BaseModel):
|
|
104
|
+
model: str
|
|
105
|
+
chunks: int
|
|
106
|
+
answers: dict[str, dict[str, Any]]
|
|
107
|
+
usage: Usage
|
opendecision/cli.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import os
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
|
|
7
|
+
import uvicorn
|
|
8
|
+
|
|
9
|
+
from opendecision import __version__
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _parser() -> argparse.ArgumentParser:
|
|
13
|
+
parser = argparse.ArgumentParser(
|
|
14
|
+
prog="opendecision",
|
|
15
|
+
description="Run the OpenDecision API server.",
|
|
16
|
+
)
|
|
17
|
+
parser.add_argument(
|
|
18
|
+
"--version",
|
|
19
|
+
action="version",
|
|
20
|
+
version=f"%(prog)s {__version__}",
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
commands = parser.add_subparsers(dest="command")
|
|
24
|
+
serve = commands.add_parser("serve", help="Start the API server.")
|
|
25
|
+
serve.add_argument("--host", default="127.0.0.1")
|
|
26
|
+
serve.add_argument("--port", type=int, default=8000)
|
|
27
|
+
serve.add_argument(
|
|
28
|
+
"--model",
|
|
29
|
+
help="Hugging Face model name or local model path.",
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
return parser
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
36
|
+
parser = _parser()
|
|
37
|
+
args = parser.parse_args(argv)
|
|
38
|
+
|
|
39
|
+
if args.command is None:
|
|
40
|
+
parser.print_help()
|
|
41
|
+
return 0
|
|
42
|
+
|
|
43
|
+
if args.model:
|
|
44
|
+
os.environ["OPENDECISION_MODEL"] = args.model
|
|
45
|
+
|
|
46
|
+
uvicorn.run(
|
|
47
|
+
"opendecision.api.app:app",
|
|
48
|
+
host=args.host,
|
|
49
|
+
port=args.port,
|
|
50
|
+
)
|
|
51
|
+
return 0
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
if __name__ == "__main__":
|
|
55
|
+
raise SystemExit(main())
|