From f12a6dac21fa5f2ead5a9846be98e7fe7207beec Mon Sep 17 00:00:00 2001 From: pateti Chandu Date: Wed, 23 Sep 2026 12:10:16 +0530 Subject: [PATCH 1/2] feat: build OpenDecision MVP --- .github/workflows/tests.yml | 22 +++++ .gitignore | 12 +++ CONTRIBUTING.md | 46 +++++++++ README.md | 132 +++++++++++++++++++++++++ app/__init__.py | 0 app/graph.py | 1 + app/main.py | 26 +++++ decision_packs/security/tool_risk.yaml | 12 +++ examples/langgraph_guard.py | 38 +++++++ examples/tool_guard.py | 17 ++++ opendecision/__init__.py | 15 +++ opendecision/guard.py | 76 ++++++++++++++ opendecision/integrations/__init__.py | 3 + opendecision/integrations/langgraph.py | 28 ++++++ opendecision/models.py | 54 ++++++++++ opendecision/policies.py | 23 +++++ opendecision/providers/__init__.py | 4 + opendecision/providers/base.py | 13 +++ opendecision/providers/laya.py | 42 ++++++++ pyproject.toml | 33 +++++++ requirements.txt | 2 + tests/test_guard.py | 29 ++++++ tests/test_langgraph.py | 14 +++ tests/test_models.py | 15 +++ tests/test_policies.py | 8 ++ 25 files changed, 665 insertions(+) create mode 100644 .github/workflows/tests.yml create mode 100644 .gitignore create mode 100644 CONTRIBUTING.md create mode 100644 app/__init__.py create mode 100644 decision_packs/security/tool_risk.yaml create mode 100644 examples/langgraph_guard.py create mode 100644 examples/tool_guard.py create mode 100644 opendecision/__init__.py create mode 100644 opendecision/guard.py create mode 100644 opendecision/integrations/__init__.py create mode 100644 opendecision/integrations/langgraph.py create mode 100644 opendecision/models.py create mode 100644 opendecision/policies.py create mode 100644 opendecision/providers/__init__.py create mode 100644 opendecision/providers/base.py create mode 100644 opendecision/providers/laya.py create mode 100644 pyproject.toml create mode 100644 tests/test_guard.py create mode 100644 tests/test_langgraph.py create mode 100644 tests/test_models.py create mode 100644 tests/test_policies.py 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..d65a929 --- /dev/null +++ b/opendecision/guard.py @@ -0,0 +1,76 @@ +"""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..2a4480b --- /dev/null +++ b/opendecision/integrations/langgraph.py @@ -0,0 +1,28 @@ +"""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..22a8dd7 --- /dev/null +++ b/opendecision/models.py @@ -0,0 +1,54 @@ +"""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 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 self.decision == value diff --git a/opendecision/policies.py b/opendecision/policies.py new file mode 100644 index 0000000..69a1e9c --- /dev/null +++ b/opendecision/policies.py @@ -0,0 +1,23 @@ +"""Small YAML-backed decision-pack loader.""" + +from pathlib import Path +from typing import Any + +import yaml +from pydantic import BaseModel, Field + +from .models import DecisionQuestion + + +class DecisionPolicy(BaseModel): + name: str + description: str = "" + question: DecisionQuestion + tags: list[str] = Field(default_factory=list) + + +def load_policy(path: str | Path) -> DecisionPolicy: + 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..e1a249b --- /dev/null +++ b/opendecision/providers/base.py @@ -0,0 +1,13 @@ +"""Provider protocol used by the decision guard.""" + +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..a9c7e03 --- /dev/null +++ b/opendecision/providers/laya.py @@ -0,0 +1,42 @@ +"""Lazy Laya provider.""" + +from collections.abc import Mapping +from typing import Any + +from ..models import DecisionQuestion + + +class LayaProvider: + 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 + 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..5bb240d --- /dev/null +++ b/tests/test_guard.py @@ -0,0 +1,29 @@ +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..b06b116 --- /dev/null +++ b/tests/test_langgraph.py @@ -0,0 +1,14 @@ +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"} From cc7a6e48c1380675376489f46d330867925255e2 Mon Sep 17 00:00:00 2001 From: pateti Chandu Date: Wed, 23 Sep 2026 12:10:58 +0530 Subject: [PATCH 2/2] fix: apply validated formatting --- opendecision/guard.py | 50 ++++++++++++++++++++++---- opendecision/integrations/langgraph.py | 18 ++++++++-- opendecision/models.py | 10 ++++-- opendecision/policies.py | 5 +++ opendecision/providers/base.py | 8 ++++- opendecision/providers/laya.py | 31 +++++++++++++--- tests/test_guard.py | 40 +++++++++++++++++---- tests/test_langgraph.py | 13 +++++-- 8 files changed, 151 insertions(+), 24 deletions(-) diff --git a/opendecision/guard.py b/opendecision/guard.py index d65a929..f03e64e 100644 --- a/opendecision/guard.py +++ b/opendecision/guard.py @@ -16,7 +16,11 @@ class DecisionGuard: 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: + 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) @@ -28,9 +32,15 @@ def check(self, state: Mapping[str, Any] | str, *, decision: str) -> DecisionRes """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: + 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") @@ -39,19 +49,47 @@ def _normalize(self, answer: Mapping[str, Any], raw_output: Any, question: Decis 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) + 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")) + 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) + 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: diff --git a/opendecision/integrations/langgraph.py b/opendecision/integrations/langgraph.py index 2a4480b..3dc0f99 100644 --- a/opendecision/integrations/langgraph.py +++ b/opendecision/integrations/langgraph.py @@ -9,20 +9,34 @@ 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]]: +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]: +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 index 22a8dd7..04f1c60 100644 --- a/opendecision/models.py +++ b/opendecision/models.py @@ -12,7 +12,9 @@ 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 @@ -20,7 +22,7 @@ class DecisionQuestion(BaseModel): threshold: float = Field(default=0.5, ge=0.0, le=1.0) @model_validator(mode="after") - def validate_shape(self) -> "DecisionQuestion": + 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: @@ -28,7 +30,7 @@ def validate_shape(self) -> "DecisionQuestion": return self @classmethod - def from_input(cls, question: "DecisionQuestion | Mapping[str, Any] | str") -> "DecisionQuestion": + def from_input(cls, question: DecisionQuestion | Mapping[str, Any] | str) -> DecisionQuestion: if isinstance(question, cls): return question if isinstance(question, str): @@ -36,12 +38,15 @@ def from_input(cls, question: "DecisionQuestion | Mapping[str, Any] | str") -> " 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) @@ -51,4 +56,5 @@ class DecisionResult(BaseModel): 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 index 69a1e9c..21e16d9 100644 --- a/opendecision/policies.py +++ b/opendecision/policies.py @@ -1,5 +1,7 @@ """Small YAML-backed decision-pack loader.""" +from __future__ import annotations + from pathlib import Path from typing import Any @@ -10,6 +12,8 @@ class DecisionPolicy(BaseModel): + """A named, reviewable decision contract stored in a decision pack.""" + name: str description: str = "" question: DecisionQuestion @@ -17,6 +21,7 @@ class DecisionPolicy(BaseModel): 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 {} diff --git a/opendecision/providers/base.py b/opendecision/providers/base.py index e1a249b..818119a 100644 --- a/opendecision/providers/base.py +++ b/opendecision/providers/base.py @@ -1,5 +1,7 @@ """Provider protocol used by the decision guard.""" +from __future__ import annotations + from collections.abc import Mapping from typing import Any, Protocol @@ -9,5 +11,9 @@ class DecisionProvider(Protocol): name: str - def predict(self, state: Mapping[str, Any] | str, question: DecisionQuestion) -> Mapping[str, Any]: + 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 index a9c7e03..d254b26 100644 --- a/opendecision/providers/laya.py +++ b/opendecision/providers/laya.py @@ -1,4 +1,10 @@ -"""Lazy Laya provider.""" +"""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 @@ -7,9 +13,17 @@ 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: + def __init__( + self, + *, + model: str = "router", + preload: bool = True, + device: str | None = None, + ) -> None: self.model = model self.preload = preload self.device = device @@ -19,9 +33,12 @@ def _load(self) -> Any: if self._agent is not None: return self._agent try: - import laya + import laya # type: ignore except ImportError as exc: - raise RuntimeError("Laya is not installed. Install it with `pip install opendecision[laya]`.") from 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: @@ -34,7 +51,11 @@ def _load(self) -> Any: 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]: + 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()}) diff --git a/tests/test_guard.py b/tests/test_guard.py index 5bb240d..efff26e 100644 --- a/tests/test_guard.py +++ b/tests/test_guard.py @@ -3,13 +3,31 @@ class FakeProvider: name = "fake" - def __init__(self, answer): self.answer = answer - def predict(self, state, question): return {"answer": self.answer, "raw": {"state": state}} + + 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"}}) + 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" @@ -17,13 +35,23 @@ def test_choice_result_is_normalized(): 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}) + 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"]}) + 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 index b06b116..01b009f 100644 --- a/tests/test_langgraph.py +++ b/tests/test_langgraph.py @@ -4,11 +4,20 @@ class FakeProvider: name = "fake" - def predict(self, state, question): return {"answer": {"choice": "allow", "confidence": 0.91}} + + 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"}}) + node = decision_node( + guard, + { + "type": "choice", + "instructions": "Proceed?", + "options": {"allow": "Proceed", "block": "Stop"}, + }, + ) update = node({"tool": "search"}) assert route_by_decision()({**update}) == "allow"