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.
@@ -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}")
@@ -0,0 +1,4 @@
1
+ from opendecision.cli import main
2
+
3
+
4
+ raise SystemExit(main())
File without changes
@@ -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())