diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..1cc0fd8 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,22 @@ +name: Tests + +on: + push: + branches: [main, "feature/**"] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - run: python -m pip install --upgrade pip + - run: python -m pip install -e ".[dev]" + - run: pytest + - run: python -m compileall opendecision diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b4e8fca --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +__pycache__/ +*.py[cod] +.pytest_cache/ +.ruff_cache/ +.mypy_cache/ +.venv/ +dist/ +build/ +*.egg-info/ +.env +.idea/ +.vscode/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..fe034db --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,46 @@ +# Contributing to OpenDecision + +Thank you for helping build a dependable decision layer for AI agents. + +## Local setup + +```bash +git clone https://github.com/Sunny-commit/OpenDecision.git +cd OpenDecision +python -m venv .venv +source .venv/bin/activate +pip install -e ".[dev]" +pytest +ruff check . +``` + +Install `.[laya]` only when testing the real provider. Unit tests should use a fake provider and must not download model weights. + +## Pull requests + +1. Open or reference an issue for substantial changes. +2. Create a focused branch. +3. Add tests and documentation. +4. Run `pytest` and `ruff check .`. +5. Explain behavior and safety implications in the pull request. + +## Decision packs + +A decision pack should: + +- solve a repeated, clearly described decision problem; +- use a typed `choice`, `score`, or `noul` question; +- define labels in precise, non-overlapping language; +- include representative evaluation examples before being advertised as production-ready; +- document calibration assumptions and failure modes; +- default to review or blocking for ambiguous consequential actions. + +Do not include secrets, personal production data, or unsupported accuracy claims. + +## Design principles + +- Keep the core framework-agnostic. +- Keep providers behind small adapters. +- Do not present generated explanations as model evidence. +- Preserve raw provider output for debugging while exposing a stable normalized result. +- Prefer explicit contracts over hidden thresholds. diff --git a/README.md b/README.md index e69de29..5d16a23 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,132 @@ +# OpenDecision + +**A framework-agnostic decision layer for AI agents.** + +OpenDecision places a typed, testable contract between an AI agent and the action it wants to take. It uses [Laya](https://github.com/NandhaKishorM/laya) for fast `choice`, `score`, and `noul` decisions without adding another text-generation step. + +> Status: early MVP. Do not treat uncalibrated model confidence as a production safety guarantee. + +## Why OpenDecision? + +Agents repeatedly need to decide whether to continue, stop, call a tool, ask a person, retrieve more context, or escalate. OpenDecision makes those branches explicit and reusable: + +- typed decision contracts instead of free-form generated text; +- normalized results across decision providers; +- community-maintained YAML decision packs; +- adapters that do not couple the core SDK to one agent framework; +- optional Laya loading, so importing OpenDecision never downloads a model. + +## Install + +```bash +pip install -e ".[laya]" +``` + +Laya and OpenDecision require Python 3.10 or newer. For the examples: + +```bash +pip install -e ".[laya,langgraph,fastapi]" +``` + +## Quick start + +```python +from opendecision import DecisionGuard + +guard = DecisionGuard() +result = guard.decide( + state={"tool": "send_email", "recipient": "customer@example.com"}, + question={ + "type": "choice", + "instructions": "Should the agent execute this tool action?", + "options": { + "allow": "Proceed automatically", + "review": "Require human approval", + "block": "Stop the action", + }, + }, +) + +print(result.decision) +print(result.probabilities) +print(result.confidence) +``` + +OpenDecision returns model decisions and evidence; it does **not** ask Laya to generate a reason. Applications may add an explanation separately without confusing generated prose with decision-model output. + +## Decision packs + +```python +from opendecision import DecisionGuard, load_policy + +policy = load_policy("decision_packs/security/tool_risk.yaml") +result = DecisionGuard().decide(agent_state, policy.question) +``` + +The first pack includes a tool-risk contract with `allow`, `review`, and `block` outcomes. + +## LangGraph + +```python +from opendecision import DecisionGuard +from opendecision.integrations.langgraph import decision_node, route_by_decision + +graph.add_node("tool_guard", decision_node(DecisionGuard(), question)) +graph.add_conditional_edges( + "tool_guard", + route_by_decision(), + {"allow": "execute_tool", "review": "human_review", "block": "stop"}, +) +``` + +## FastAPI + +```bash +uvicorn app.main:app --reload +``` + +Then send `POST /v1/decide` with `state` and a typed `question`. + +## Architecture + +```text +Agent / application + | +DecisionGuard + contract + | +Provider adapter (Laya first) + | +Normalized DecisionResult + | +ALLOW | REVIEW | BLOCK +``` + +## Scope of v0.1 + +- Python SDK and Pydantic contracts +- lazy Laya provider +- choice, score, and noul normalization +- YAML decision packs +- LangGraph node and routing helpers +- FastAPI example +- unit tests that run without downloading model weights + +A dashboard, TypeScript SDK, additional frameworks, model-generated explanations, and evaluation tooling are intentionally deferred. + +## Calibration and safety + +Laya's documentation notes meaningful limitations: base checkpoints can be weak zero-shot on typed workflows, confidence may require domain calibration, and high-cardinality choices need special handling. Benchmark decision packs on representative data before automating consequential actions. Prefer human review when evidence or authorization is insufficient. + +## Development + +```bash +pip install -e ".[dev]" +pytest +ruff check . +``` + +See [CONTRIBUTING.md](CONTRIBUTING.md) for contribution guidelines. + +## License + +Apache License 2.0. Laya is a separate Apache-2.0 project and remains subject to its own license and notices. diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/graph.py b/app/graph.py index e69de29..8265d8f 100644 --- a/app/graph.py +++ b/app/graph.py @@ -0,0 +1 @@ +"""Compatibility module; see examples/langgraph_guard.py for a complete graph.""" diff --git a/app/main.py b/app/main.py index e69de29..c813cf0 100644 --- a/app/main.py +++ b/app/main.py @@ -0,0 +1,26 @@ +"""Minimal FastAPI service for OpenDecision.""" + +from typing import Any + +from fastapi import FastAPI +from pydantic import BaseModel + +from opendecision import DecisionGuard, DecisionQuestion, DecisionResult + +app = FastAPI(title="OpenDecision", version="0.1.0") +guard = DecisionGuard() + + +class DecisionRequest(BaseModel): + state: dict[str, Any] | str + question: DecisionQuestion + + +@app.get("/health") +def health() -> dict[str, str]: + return {"status": "ok"} + + +@app.post("/v1/decide", response_model=DecisionResult) +def decide(request: DecisionRequest) -> DecisionResult: + return guard.decide(request.state, request.question) diff --git a/decision_packs/security/tool_risk.yaml b/decision_packs/security/tool_risk.yaml new file mode 100644 index 0000000..408cf13 --- /dev/null +++ b/decision_packs/security/tool_risk.yaml @@ -0,0 +1,12 @@ +name: tool-risk +description: Classify whether an agent tool action should proceed, be reviewed, or be blocked. +tags: + - security + - tool-use +question: + type: choice + instructions: Should the agent execute this tool action? + options: + allow: The action is low risk and can proceed automatically. + review: The action needs human approval before execution. + block: The action is destructive, unsafe, or not authorized. diff --git a/examples/langgraph_guard.py b/examples/langgraph_guard.py new file mode 100644 index 0000000..e796a08 --- /dev/null +++ b/examples/langgraph_guard.py @@ -0,0 +1,38 @@ +"""Insert OpenDecision into a LangGraph workflow.""" + +from langgraph.graph import END, StateGraph +from typing_extensions import TypedDict + +from opendecision import DecisionGuard +from opendecision.integrations.langgraph import decision_node, route_by_decision + + +class AgentState(TypedDict, total=False): + tool: str + arguments: dict + decision_result: dict + + +graph = StateGraph(AgentState) +graph.add_node( + "guard", + decision_node( + DecisionGuard(), + { + "type": "choice", + "instructions": "Should the agent execute this tool action?", + "options": { + "allow": "Proceed automatically", + "review": "Require human approval", + "block": "Stop the action", + }, + }, + ), +) +graph.set_entry_point("guard") +graph.add_conditional_edges( + "guard", + route_by_decision(), + {"allow": END, "review": END, "block": END}, +) +app = graph.compile() diff --git a/examples/tool_guard.py b/examples/tool_guard.py new file mode 100644 index 0000000..28e19ad --- /dev/null +++ b/examples/tool_guard.py @@ -0,0 +1,17 @@ +"""Guard a tool call with a reusable decision contract.""" + +from opendecision import DecisionGuard, load_policy + +policy = load_policy("decision_packs/security/tool_risk.yaml") +guard = DecisionGuard() + +result = guard.decide( + state={ + "tool": "delete_database", + "arguments": {"database": "production"}, + "user_confirmed": False, + }, + question=policy.question, +) + +print(result.model_dump_json(indent=2)) diff --git a/opendecision/__init__.py b/opendecision/__init__.py new file mode 100644 index 0000000..1a3224e --- /dev/null +++ b/opendecision/__init__.py @@ -0,0 +1,15 @@ +"""OpenDecision: a typed decision layer for AI agents.""" + +from .guard import DecisionGuard +from .models import DecisionQuestion, DecisionResult +from .policies import DecisionPolicy, load_policy + +__all__ = [ + "DecisionGuard", + "DecisionQuestion", + "DecisionResult", + "DecisionPolicy", + "load_policy", +] + +__version__ = "0.1.0" diff --git a/opendecision/guard.py b/opendecision/guard.py new file mode 100644 index 0000000..f03e64e --- /dev/null +++ b/opendecision/guard.py @@ -0,0 +1,114 @@ +"""DecisionGuard and provider-output normalization.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from .models import DecisionQuestion, DecisionResult +from .providers.base import DecisionProvider +from .providers.laya import LayaProvider + + +class DecisionGuard: + """Evaluate typed decision contracts before an agent takes an action.""" + + def __init__(self, provider: DecisionProvider | None = None, *, model: str = "router") -> None: + self.provider = provider or LayaProvider(model=model) + + def decide( + self, + state: Mapping[str, Any] | str, + question: DecisionQuestion | Mapping[str, Any] | str, + ) -> DecisionResult: + """Evaluate a choice, score, or noul question and normalize its result.""" + contract = DecisionQuestion.from_input(question) + output = self.provider.predict(state, contract) + answer = output.get("answer", output) + raw_output = output.get("raw", output) + return self._normalize(answer, raw_output, contract) + + def check(self, state: Mapping[str, Any] | str, *, decision: str) -> DecisionResult: + """Convenience method for a boolean allow/block gate.""" + return self.decide(state, DecisionQuestion(type="noul", instructions=decision)) + + def _normalize( + self, + answer: Mapping[str, Any], + raw_output: Any, + question: DecisionQuestion, + ) -> DecisionResult: + if not isinstance(answer, Mapping): + raise TypeError("decision provider must return a mapping for its answer") + + if question.type == "choice": + probabilities = _float_mapping(answer.get("probabilities") or answer.get("probs")) + decision = _first(answer, "choice", "label", "decision") + if decision is None: + raise ValueError("provider response did not contain a choice decision") + confidence = _as_probability(_first(answer, "confidence")) + if confidence is None and probabilities: + confidence = max(probabilities.values()) + return DecisionResult( + decision=str(decision), + probabilities=probabilities, + confidence=confidence, + question_type="choice", + provider=self.provider.name, + metadata={"raw_answer": dict(answer)}, + raw_output=raw_output, + ) + + if question.type == "score": + score = _first(answer, "score", "value", "decision") + if score is None: + raise ValueError("provider response did not contain a score decision") + distribution = _float_mapping(answer.get("distribution") or answer.get("probabilities")) + confidence = _as_probability(_first(answer, "confidence")) + return DecisionResult( + decision=float(score), + probabilities=distribution, + confidence=confidence, + question_type="score", + provider=self.provider.name, + metadata={"raw_answer": dict(answer)}, + raw_output=raw_output, + ) + + probability = _as_probability( + _first(answer, "noul", "probability", "confidence", "decision") + ) + if probability is None: + raise ValueError("provider response did not contain a noul probability") + decision = probability >= question.threshold + return DecisionResult( + decision=decision, + probabilities={"true": probability, "false": 1.0 - probability}, + confidence=max(probability, 1.0 - probability), + question_type="noul", + provider=self.provider.name, + metadata={"threshold": question.threshold, "raw_answer": dict(answer)}, + raw_output=raw_output, + ) + + +def _first(mapping: Mapping[str, Any], *keys: str) -> Any: + for key in keys: + if mapping.get(key) is not None: + return mapping[key] + return None + + +def _as_probability(value: Any) -> float | None: + if value is None: + return None + value = float(value) + if not 0.0 <= value <= 1.0: + raise ValueError(f"probability must be between 0 and 1, got {value}") + return value + + +def _float_mapping(value: Any) -> dict[str, float]: + if not isinstance(value, Mapping): + return {} + return {str(key): float(probability) for key, probability in value.items()} diff --git a/opendecision/integrations/__init__.py b/opendecision/integrations/__init__.py new file mode 100644 index 0000000..106eed8 --- /dev/null +++ b/opendecision/integrations/__init__.py @@ -0,0 +1,3 @@ +from .langgraph import decision_node, route_by_decision + +__all__ = ["decision_node", "route_by_decision"] diff --git a/opendecision/integrations/langgraph.py b/opendecision/integrations/langgraph.py new file mode 100644 index 0000000..3dc0f99 --- /dev/null +++ b/opendecision/integrations/langgraph.py @@ -0,0 +1,42 @@ +"""LangGraph-compatible helpers without a hard dependency on LangGraph.""" + +from __future__ import annotations + +from collections.abc import Callable, Mapping +from typing import Any + +from ..guard import DecisionGuard +from ..models import DecisionQuestion + + +def decision_node( + guard: DecisionGuard, + question: DecisionQuestion | Mapping[str, Any] | str, + *, + state_key: str | None = None, + result_key: str = "decision_result", +) -> Callable[[Mapping[str, Any]], dict[str, Any]]: + """Create a node function that returns a serializable decision result.""" + + def node(state: Mapping[str, Any]) -> dict[str, Any]: + decision_state: Any = state[state_key] if state_key else state + result = guard.decide(decision_state, question) + return {result_key: result.model_dump()} + + return node + + +def route_by_decision( + *, + result_key: str = "decision_result", + decision_field: str = "decision", +) -> Callable[[Mapping[str, Any]], Any]: + """Create a conditional-edge function for a LangGraph graph.""" + + def route(state: Mapping[str, Any]) -> Any: + result = state[result_key] + if isinstance(result, Mapping): + return result[decision_field] + return getattr(result, decision_field) + + return route diff --git a/opendecision/models.py b/opendecision/models.py new file mode 100644 index 0000000..04f1c60 --- /dev/null +++ b/opendecision/models.py @@ -0,0 +1,60 @@ +"""Public data models and input normalization for OpenDecision.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +DecisionType = Literal["choice", "score", "noul"] + + +class DecisionQuestion(BaseModel): + """A typed question sent to a decision provider.""" + + model_config = ConfigDict(extra="allow") + + type: DecisionType + instructions: str + options: dict[str, str] | None = None + criteria: list[str] | dict[str, str] | None = None + threshold: float = Field(default=0.5, ge=0.0, le=1.0) + + @model_validator(mode="after") + def validate_shape(self) -> DecisionQuestion: + if self.type == "choice" and not self.options and not self.criteria: + raise ValueError("choice questions require options or criteria") + if self.type == "score" and not self.criteria: + raise ValueError("score questions require criteria") + return self + + @classmethod + def from_input(cls, question: DecisionQuestion | Mapping[str, Any] | str) -> DecisionQuestion: + if isinstance(question, cls): + return question + if isinstance(question, str): + return cls(type="noul", instructions=question) + return cls.model_validate(question) + + def to_laya(self) -> dict[str, Any]: + """Return the question shape expected by Laya.""" + return self.model_dump(exclude_none=True) + + +class DecisionResult(BaseModel): + """A normalized, provider-independent decision result.""" + + model_config = ConfigDict(arbitrary_types_allowed=True) + + decision: str | float | bool + probabilities: dict[str, float] = Field(default_factory=dict) + confidence: float | None = Field(default=None, ge=0.0, le=1.0) + question_type: DecisionType + provider: str + metadata: dict[str, Any] = Field(default_factory=dict) + raw_output: Any = None + + def matches(self, value: str | float | bool) -> bool: + """Return whether the normalized decision equals a value.""" + return self.decision == value diff --git a/opendecision/policies.py b/opendecision/policies.py new file mode 100644 index 0000000..21e16d9 --- /dev/null +++ b/opendecision/policies.py @@ -0,0 +1,28 @@ +"""Small YAML-backed decision-pack loader.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import yaml +from pydantic import BaseModel, Field + +from .models import DecisionQuestion + + +class DecisionPolicy(BaseModel): + """A named, reviewable decision contract stored in a decision pack.""" + + name: str + description: str = "" + question: DecisionQuestion + tags: list[str] = Field(default_factory=list) + + +def load_policy(path: str | Path) -> DecisionPolicy: + """Load a policy from a YAML file.""" + policy_path = Path(path) + with policy_path.open("r", encoding="utf-8") as handle: + payload: dict[str, Any] = yaml.safe_load(handle) or {} + return DecisionPolicy.model_validate(payload) diff --git a/opendecision/providers/__init__.py b/opendecision/providers/__init__.py new file mode 100644 index 0000000..707e9db --- /dev/null +++ b/opendecision/providers/__init__.py @@ -0,0 +1,4 @@ +from .base import DecisionProvider +from .laya import LayaProvider + +__all__ = ["DecisionProvider", "LayaProvider"] diff --git a/opendecision/providers/base.py b/opendecision/providers/base.py new file mode 100644 index 0000000..818119a --- /dev/null +++ b/opendecision/providers/base.py @@ -0,0 +1,19 @@ +"""Provider protocol used by the decision guard.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, Protocol + +from ..models import DecisionQuestion + + +class DecisionProvider(Protocol): + name: str + + def predict( + self, + state: Mapping[str, Any] | str, + question: DecisionQuestion, + ) -> Mapping[str, Any]: + """Return a provider-native answer and the raw response.""" diff --git a/opendecision/providers/laya.py b/opendecision/providers/laya.py new file mode 100644 index 0000000..d254b26 --- /dev/null +++ b/opendecision/providers/laya.py @@ -0,0 +1,63 @@ +"""Lazy Laya provider. + +Laya is optional so the core package can be installed and tested without model +weights. The import and checkpoint loading happen only on the first prediction. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from ..models import DecisionQuestion + + +class LayaProvider: + """Use Laya's Router or a named checkpoint as an OpenDecision provider.""" + + name = "laya" + + def __init__( + self, + *, + model: str = "router", + preload: bool = True, + device: str | None = None, + ) -> None: + self.model = model + self.preload = preload + self.device = device + self._agent: Any = None + + def _load(self) -> Any: + if self._agent is not None: + return self._agent + try: + import laya # type: ignore + except ImportError as exc: + raise RuntimeError( + "Laya is not installed. Install it with `pip install opendecision[laya]`." + ) from exc + + if self.model == "router": + kwargs: dict[str, Any] = {"preload": self.preload} + if self.device is not None: + kwargs["device"] = self.device + self._agent = laya.Router(**kwargs) + else: + kwargs = {} + if self.device is not None: + kwargs["device"] = self.device + self._agent = laya.load("convaiinnovations/laya", subfolder=self.model, **kwargs) + return self._agent + + def predict( + self, + state: Mapping[str, Any] | str, + question: DecisionQuestion, + ) -> Mapping[str, Any]: + agent = self._load() + state_payload = state if isinstance(state, Mapping) else {"input": state} + response = agent.predict(state_payload, {"decision": question.to_laya()}) + answer = response.get("answers", {}).get("decision", {}) + return {"answer": answer, "raw": response} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..e1e757e --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,33 @@ +[build-system] +requires = ["hatchling>=1.25"] +build-backend = "hatchling.build" + +[project] +name = "opendecision" +version = "0.1.0" +description = "A typed decision layer for AI agents" +readme = "README.md" +requires-python = ">=3.10" +license = { text = "Apache-2.0" } +authors = [{ name = "OpenDecision contributors" }] +dependencies = ["pydantic>=2.7,<3", "PyYAML>=6.0"] + +[project.optional-dependencies] +laya = ["laya"] +langgraph = ["langgraph>=0.2"] +fastapi = ["fastapi>=0.115", "uvicorn>=0.30"] +dev = ["pytest>=8.0", "ruff>=0.6", "mypy>=1.10"] + +[tool.hatch.build.targets.wheel] +packages = ["opendecision"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-q" + +[tool.ruff] +line-length = 100 +target-version = "py310" + +[tool.ruff.lint] +select = ["E", "F", "I", "UP"] diff --git a/requirements.txt b/requirements.txt index e69de29..642d1cf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -0,0 +1,2 @@ +pydantic>=2.7,<3 +PyYAML>=6.0 diff --git a/tests/test_guard.py b/tests/test_guard.py new file mode 100644 index 0000000..efff26e --- /dev/null +++ b/tests/test_guard.py @@ -0,0 +1,57 @@ +from opendecision import DecisionGuard + + +class FakeProvider: + name = "fake" + + def __init__(self, answer): + self.answer = answer + + def predict(self, state, question): + return {"answer": self.answer, "raw": {"state": state}} + + +def test_choice_result_is_normalized(): + guard = DecisionGuard( + FakeProvider( + { + "choice": "review", + "probabilities": {"allow": 0.1, "review": 0.8, "block": 0.1}, + } + ) + ) + result = guard.decide( + {"tool": "send_email"}, + { + "type": "choice", + "instructions": "Should this action execute?", + "options": {"allow": "Proceed", "review": "Review", "block": "Block"}, + }, + ) + assert result.decision == "review" + assert result.confidence == 0.8 + assert result.provider == "fake" + + +def test_noul_threshold_controls_boolean_decision(): + guard = DecisionGuard(FakeProvider({"noul": 0.72})) + result = guard.decide( + {"command": "rm -rf /"}, + {"type": "noul", "instructions": "Is this destructive?", "threshold": 0.7}, + ) + assert result.decision is True + assert result.probabilities == {"true": 0.72, "false": 0.28} + + +def test_score_is_normalized(): + guard = DecisionGuard(FakeProvider({"score": 1.5, "confidence": 0.9})) + result = guard.decide( + {"ticket": "Production is unavailable"}, + { + "type": "score", + "instructions": "How urgent is this?", + "criteria": ["low", "medium", "critical"], + }, + ) + assert result.decision == 1.5 + assert result.confidence == 0.9 diff --git a/tests/test_langgraph.py b/tests/test_langgraph.py new file mode 100644 index 0000000..01b009f --- /dev/null +++ b/tests/test_langgraph.py @@ -0,0 +1,23 @@ +from opendecision import DecisionGuard +from opendecision.integrations.langgraph import decision_node, route_by_decision + + +class FakeProvider: + name = "fake" + + def predict(self, state, question): + return {"answer": {"choice": "allow", "confidence": 0.91}} + + +def test_langgraph_node_and_router(): + guard = DecisionGuard(FakeProvider()) + node = decision_node( + guard, + { + "type": "choice", + "instructions": "Proceed?", + "options": {"allow": "Proceed", "block": "Stop"}, + }, + ) + update = node({"tool": "search"}) + assert route_by_decision()({**update}) == "allow" diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..9e15da0 --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,15 @@ +import pytest +from pydantic import ValidationError + +from opendecision import DecisionQuestion + + +def test_string_question_becomes_noul_contract(): + question = DecisionQuestion.from_input("Is this safe?") + assert question.type == "noul" + assert question.instructions == "Is this safe?" + + +def test_choice_requires_options_or_criteria(): + with pytest.raises(ValidationError): + DecisionQuestion(type="choice", instructions="Choose") diff --git a/tests/test_policies.py b/tests/test_policies.py new file mode 100644 index 0000000..598ad12 --- /dev/null +++ b/tests/test_policies.py @@ -0,0 +1,8 @@ +from opendecision import load_policy + + +def test_load_tool_risk_policy(): + policy = load_policy("decision_packs/security/tool_risk.yaml") + assert policy.name == "tool-risk" + assert policy.question.type == "choice" + assert set(policy.question.options or {}) == {"allow", "review", "block"}