drex-policy 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nace.AI
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,190 @@
1
+ Metadata-Version: 2.4
2
+ Name: drex-policy
3
+ Version: 0.1.0
4
+ Summary: Guardrails validator that checks text, or text with images, against a Drex noul or score question.
5
+ Author-email: "Nace.AI" <alish@nace.ai>
6
+ License-Expression: MIT
7
+ Requires-Python: <4,>=3.10
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ Requires-Dist: guardrails-ai>=0.4.0
11
+ Requires-Dist: nace-sdk>=0.6.2
12
+ Provides-Extra: dev
13
+ Requires-Dist: pyright; extra == "dev"
14
+ Requires-Dist: pytest; extra == "dev"
15
+ Requires-Dist: ruff; extra == "dev"
16
+ Dynamic: license-file
17
+
18
+ # drex-policy
19
+
20
+ A [Guardrails](https://github.com/guardrails-ai/guardrails) validator that asks Drex, Nace.AI's decision model, one question about a string.
21
+
22
+ | Developed by | Nace.AI |
23
+ | --- | --- |
24
+ | Date of development | Oct 29, 2026 |
25
+ | Validator type | Moderation, policy |
26
+ | License | MIT |
27
+ | Input/Output | Input, Output |
28
+
29
+ ## What it checks
30
+
31
+ `DrexPolicy` asks [Drex](https://console.nace.ai/docs) one question about a string and fails when the answer crosses a threshold you set. Use it for checks such as PII present, off-topic, toxicity level, jailbreak attempt, or competitor mention.
32
+
33
+ The question is a `nace_sdk.Noul` (probability of a yes answer) or a `nace_sdk.Score` (expected index over criteria you list). Drex returns the probability. You choose the threshold with `fail_at`, preferably from labeled examples from your application.
34
+
35
+ - Noul: fails when P(yes) is at or above `fail_at`. With `fail_when="no"`, fails when P(yes) is below `fail_at`.
36
+ - Score: fails when the score is at or above `fail_at`. With `fail_when="below"`, fails when the score is below `fail_at`.
37
+
38
+ The Drex answer is in the result metadata on pass and on fail.
39
+
40
+ ## Models
41
+
42
+ The default model is `drex-v2.0`. Text-only checks can use any Drex model id, which is sent unchanged. `drex-latest` is sent as `drex-latest`. It is not `drex-max`.
43
+
44
+ To check images with the text, pass `model="drex-max"` and put the images in `metadata["images"]`. Each image is a URL or a data URL (PNG, JPEG, or WebP). `drex-max` accepts at most 8 images. If images are present and the model is not `drex-max`, or there are more than 8 images, the validator raises `DrexPolicyError` before it calls Drex. It does not drop the images.
45
+
46
+ ## Requirements
47
+
48
+ * Dependencies:
49
+ - guardrails-ai>=0.4.0
50
+ - nace-sdk>=0.6.2
51
+
52
+ * Access keys:
53
+ - `NACE_API_KEY`: a key from [console.nace.ai](https://console.nace.ai)
54
+ - `NACE_BASE_URL` (optional): defaults to `https://console.nace.ai`
55
+
56
+ When the validator builds its own client, each HTTP attempt times out after 60 seconds, the total retry budget is 180 seconds, and requests carry `X-Nace-Client: drex-policy/0.1.0`.
57
+
58
+ ## Install
59
+
60
+ ```bash
61
+ $ guardrails hub install hub://nace/drex_policy
62
+ ```
63
+
64
+ You can also install the package from PyPI:
65
+
66
+ ```bash
67
+ $ pip install drex-policy
68
+ ```
69
+
70
+ With a PyPI install, import the validator from `drex_policy` instead of `guardrails.hub`:
71
+
72
+ ```python
73
+ from drex_policy import DrexPolicy
74
+ ```
75
+
76
+ The examples below use the hub import. The class and its arguments are the same.
77
+
78
+ ## Examples
79
+
80
+ ### Check a string
81
+
82
+ ```python
83
+ from guardrails import Guard
84
+ from guardrails.hub import DrexPolicy
85
+ from nace_sdk import Noul
86
+
87
+ guard = Guard().use(
88
+ DrexPolicy(
89
+ question=Noul(instructions="Does the text contain a personal phone number or email address?"),
90
+ fail_at=0.5,
91
+ on_fail="exception",
92
+ )
93
+ )
94
+
95
+ guard.validate("Our office opens at 9am.") # Validator passes
96
+ guard.validate("Call me on +1 415 555 0100.") # Validator fails
97
+ ```
98
+
99
+ ### Check text with images
100
+
101
+ ```python
102
+ from guardrails import Guard
103
+ from guardrails.hub import DrexPolicy
104
+ from nace_sdk import Score
105
+
106
+ guard = Guard().use(
107
+ DrexPolicy(
108
+ question=Score(
109
+ instructions="How unsafe is this listing for a general audience?",
110
+ criteria=["safe", "borderline", "unsafe"],
111
+ ),
112
+ fail_at=1.5,
113
+ model="drex-max",
114
+ on_fail="exception",
115
+ )
116
+ )
117
+
118
+ guard.validate(
119
+ "Vintage camera, works well.",
120
+ metadata={"images": ["https://example.com/listing.jpg"]},
121
+ )
122
+ ```
123
+
124
+ ### Check one field of structured output
125
+
126
+ ```python
127
+ from pydantic import BaseModel, Field
128
+ from guardrails import Guard
129
+ from guardrails.hub import DrexPolicy
130
+ from nace_sdk import Noul
131
+
132
+ off_topic = DrexPolicy(
133
+ question=Noul(instructions="Is this reply about something other than our billing product?"),
134
+ fail_at=0.5,
135
+ on_fail="exception",
136
+ )
137
+
138
+
139
+ class Reply(BaseModel):
140
+ answer: str = Field(validators=[off_topic])
141
+
142
+
143
+ guard = Guard.for_pydantic(output_class=Reply)
144
+ guard.parse('{"answer": "Your invoice is in Settings > Billing."}')
145
+ ```
146
+
147
+ ## API reference
148
+
149
+ **`__init__(self, question, fail_at, fail_when=None, question_id="policy", model=None, client=None, on_fail=None)`**
150
+ <ul>
151
+
152
+ **Parameters**
153
+ - **`question`** *(nace_sdk.Noul | nace_sdk.Score)*: The question Drex answers about the value. If `instructions` is omitted, it stays `None` and is not sent.
154
+ - **`fail_at`** *(float)*: For a noul, a P(yes) threshold between 0 and 1. For a score, a finite expected-index threshold.
155
+ - **`fail_when`** *(str)*: `"yes"` (default) or `"no"` for a noul. `"at_or_above"` (default) or `"below"` for a score.
156
+ - **`question_id`** *(str)*: Key of the question in the Drex request and response. Default `"policy"`.
157
+ - **`model`** *(str)*: Drex model id. Default `drex-v2.0`. Images need `drex-max`.
158
+ - **`client`** *(nace_sdk.NaceClient)*: An existing client. If omitted, the validator builds one that reads `NACE_API_KEY` and `NACE_BASE_URL`.
159
+ - **`on_fail`** *(str, Callable)*: What Guardrails does on a fail: one of `exception`, `noop`, `reask`, `fix`, `filter`, `refrain` or `fix_reask`, or a function that Guardrails calls on a fail.
160
+ </ul>
161
+ <br/>
162
+
163
+ **`validate(self, value, metadata) -> ValidationResult`**
164
+ <ul>
165
+ Asks Drex the question about `value` and compares the answer with `fail_at`. Do not call it directly. `guard.parse(...)` and `guard.validate(...)` call it.
166
+
167
+ If Drex cannot answer (a network error, an API error, or a response without the expected answer), the exception propagates. The validator does not turn it into a pass or a fail.
168
+
169
+ **Parameters**
170
+ - **`value`** *(str)*: The text to check.
171
+ - **`metadata`** *(dict)*: Optional.
172
+
173
+ | Key | Type | Description | Default |
174
+ | --- | --- | --- | --- |
175
+ | `images` | list[str] | Image URLs or data URLs. Needs `model="drex-max"`; at most 8. | None |
176
+
177
+ **Result metadata**
178
+
179
+ | Key | Description |
180
+ | --- | --- |
181
+ | `drex_model` | Model id sent to Drex |
182
+ | `drex_question_id` | The `question_id` |
183
+ | `drex_answer` | The Drex answer, for example `{"type": "noul", "noul": 0.93}` |
184
+ </ul>
185
+
186
+ ## License
187
+
188
+ MIT. Copyright (c) 2026 Nace.AI. See [LICENSE](LICENSE).
189
+
190
+ Scaffolding from [guardrails-ai/validator-template](https://github.com/guardrails-ai/validator-template) (Apache-2.0).
@@ -0,0 +1,173 @@
1
+ # drex-policy
2
+
3
+ A [Guardrails](https://github.com/guardrails-ai/guardrails) validator that asks Drex, Nace.AI's decision model, one question about a string.
4
+
5
+ | Developed by | Nace.AI |
6
+ | --- | --- |
7
+ | Date of development | Oct 29, 2026 |
8
+ | Validator type | Moderation, policy |
9
+ | License | MIT |
10
+ | Input/Output | Input, Output |
11
+
12
+ ## What it checks
13
+
14
+ `DrexPolicy` asks [Drex](https://console.nace.ai/docs) one question about a string and fails when the answer crosses a threshold you set. Use it for checks such as PII present, off-topic, toxicity level, jailbreak attempt, or competitor mention.
15
+
16
+ The question is a `nace_sdk.Noul` (probability of a yes answer) or a `nace_sdk.Score` (expected index over criteria you list). Drex returns the probability. You choose the threshold with `fail_at`, preferably from labeled examples from your application.
17
+
18
+ - Noul: fails when P(yes) is at or above `fail_at`. With `fail_when="no"`, fails when P(yes) is below `fail_at`.
19
+ - Score: fails when the score is at or above `fail_at`. With `fail_when="below"`, fails when the score is below `fail_at`.
20
+
21
+ The Drex answer is in the result metadata on pass and on fail.
22
+
23
+ ## Models
24
+
25
+ The default model is `drex-v2.0`. Text-only checks can use any Drex model id, which is sent unchanged. `drex-latest` is sent as `drex-latest`. It is not `drex-max`.
26
+
27
+ To check images with the text, pass `model="drex-max"` and put the images in `metadata["images"]`. Each image is a URL or a data URL (PNG, JPEG, or WebP). `drex-max` accepts at most 8 images. If images are present and the model is not `drex-max`, or there are more than 8 images, the validator raises `DrexPolicyError` before it calls Drex. It does not drop the images.
28
+
29
+ ## Requirements
30
+
31
+ * Dependencies:
32
+ - guardrails-ai>=0.4.0
33
+ - nace-sdk>=0.6.2
34
+
35
+ * Access keys:
36
+ - `NACE_API_KEY`: a key from [console.nace.ai](https://console.nace.ai)
37
+ - `NACE_BASE_URL` (optional): defaults to `https://console.nace.ai`
38
+
39
+ When the validator builds its own client, each HTTP attempt times out after 60 seconds, the total retry budget is 180 seconds, and requests carry `X-Nace-Client: drex-policy/0.1.0`.
40
+
41
+ ## Install
42
+
43
+ ```bash
44
+ $ guardrails hub install hub://nace/drex_policy
45
+ ```
46
+
47
+ You can also install the package from PyPI:
48
+
49
+ ```bash
50
+ $ pip install drex-policy
51
+ ```
52
+
53
+ With a PyPI install, import the validator from `drex_policy` instead of `guardrails.hub`:
54
+
55
+ ```python
56
+ from drex_policy import DrexPolicy
57
+ ```
58
+
59
+ The examples below use the hub import. The class and its arguments are the same.
60
+
61
+ ## Examples
62
+
63
+ ### Check a string
64
+
65
+ ```python
66
+ from guardrails import Guard
67
+ from guardrails.hub import DrexPolicy
68
+ from nace_sdk import Noul
69
+
70
+ guard = Guard().use(
71
+ DrexPolicy(
72
+ question=Noul(instructions="Does the text contain a personal phone number or email address?"),
73
+ fail_at=0.5,
74
+ on_fail="exception",
75
+ )
76
+ )
77
+
78
+ guard.validate("Our office opens at 9am.") # Validator passes
79
+ guard.validate("Call me on +1 415 555 0100.") # Validator fails
80
+ ```
81
+
82
+ ### Check text with images
83
+
84
+ ```python
85
+ from guardrails import Guard
86
+ from guardrails.hub import DrexPolicy
87
+ from nace_sdk import Score
88
+
89
+ guard = Guard().use(
90
+ DrexPolicy(
91
+ question=Score(
92
+ instructions="How unsafe is this listing for a general audience?",
93
+ criteria=["safe", "borderline", "unsafe"],
94
+ ),
95
+ fail_at=1.5,
96
+ model="drex-max",
97
+ on_fail="exception",
98
+ )
99
+ )
100
+
101
+ guard.validate(
102
+ "Vintage camera, works well.",
103
+ metadata={"images": ["https://example.com/listing.jpg"]},
104
+ )
105
+ ```
106
+
107
+ ### Check one field of structured output
108
+
109
+ ```python
110
+ from pydantic import BaseModel, Field
111
+ from guardrails import Guard
112
+ from guardrails.hub import DrexPolicy
113
+ from nace_sdk import Noul
114
+
115
+ off_topic = DrexPolicy(
116
+ question=Noul(instructions="Is this reply about something other than our billing product?"),
117
+ fail_at=0.5,
118
+ on_fail="exception",
119
+ )
120
+
121
+
122
+ class Reply(BaseModel):
123
+ answer: str = Field(validators=[off_topic])
124
+
125
+
126
+ guard = Guard.for_pydantic(output_class=Reply)
127
+ guard.parse('{"answer": "Your invoice is in Settings > Billing."}')
128
+ ```
129
+
130
+ ## API reference
131
+
132
+ **`__init__(self, question, fail_at, fail_when=None, question_id="policy", model=None, client=None, on_fail=None)`**
133
+ <ul>
134
+
135
+ **Parameters**
136
+ - **`question`** *(nace_sdk.Noul | nace_sdk.Score)*: The question Drex answers about the value. If `instructions` is omitted, it stays `None` and is not sent.
137
+ - **`fail_at`** *(float)*: For a noul, a P(yes) threshold between 0 and 1. For a score, a finite expected-index threshold.
138
+ - **`fail_when`** *(str)*: `"yes"` (default) or `"no"` for a noul. `"at_or_above"` (default) or `"below"` for a score.
139
+ - **`question_id`** *(str)*: Key of the question in the Drex request and response. Default `"policy"`.
140
+ - **`model`** *(str)*: Drex model id. Default `drex-v2.0`. Images need `drex-max`.
141
+ - **`client`** *(nace_sdk.NaceClient)*: An existing client. If omitted, the validator builds one that reads `NACE_API_KEY` and `NACE_BASE_URL`.
142
+ - **`on_fail`** *(str, Callable)*: What Guardrails does on a fail: one of `exception`, `noop`, `reask`, `fix`, `filter`, `refrain` or `fix_reask`, or a function that Guardrails calls on a fail.
143
+ </ul>
144
+ <br/>
145
+
146
+ **`validate(self, value, metadata) -> ValidationResult`**
147
+ <ul>
148
+ Asks Drex the question about `value` and compares the answer with `fail_at`. Do not call it directly. `guard.parse(...)` and `guard.validate(...)` call it.
149
+
150
+ If Drex cannot answer (a network error, an API error, or a response without the expected answer), the exception propagates. The validator does not turn it into a pass or a fail.
151
+
152
+ **Parameters**
153
+ - **`value`** *(str)*: The text to check.
154
+ - **`metadata`** *(dict)*: Optional.
155
+
156
+ | Key | Type | Description | Default |
157
+ | --- | --- | --- | --- |
158
+ | `images` | list[str] | Image URLs or data URLs. Needs `model="drex-max"`; at most 8. | None |
159
+
160
+ **Result metadata**
161
+
162
+ | Key | Description |
163
+ | --- | --- |
164
+ | `drex_model` | Model id sent to Drex |
165
+ | `drex_question_id` | The `question_id` |
166
+ | `drex_answer` | The Drex answer, for example `{"type": "noul", "noul": 0.93}` |
167
+ </ul>
168
+
169
+ ## License
170
+
171
+ MIT. Copyright (c) 2026 Nace.AI. See [LICENSE](LICENSE).
172
+
173
+ Scaffolding from [guardrails-ai/validator-template](https://github.com/guardrails-ai/validator-template) (Apache-2.0).
@@ -0,0 +1,3 @@
1
+ from .main import DrexPolicy
2
+
3
+ __all__ = ["DrexPolicy"]
@@ -0,0 +1,172 @@
1
+ import math
2
+ from collections.abc import Callable, Mapping, Sequence
3
+ from typing import Any, Protocol
4
+
5
+ from guardrails.validator_base import (
6
+ FailResult,
7
+ PassResult,
8
+ ValidationResult,
9
+ Validator,
10
+ register_validator,
11
+ )
12
+ from nace_sdk import NaceClient, Noul, NoulAnswer, RetryPolicy, Score, ScoreAnswer
13
+
14
+ VERSION = "0.1.0"
15
+ CLIENT_INFO = f"drex-policy/{VERSION}"
16
+ DEFAULT_MODEL = "drex-v2.0"
17
+ IMAGE_MODEL = "drex-max"
18
+ MAX_IMAGES = 8
19
+
20
+ Question = Noul | Score
21
+
22
+
23
+ class DrexPolicyError(Exception):
24
+ """Drex could not decide the check. This is not a policy fail."""
25
+
26
+
27
+ class SystemOneCaller(Protocol):
28
+ def system_one(
29
+ self,
30
+ state: str,
31
+ questions: Mapping[str, Question],
32
+ *,
33
+ model: str | None = None,
34
+ images: Sequence[str] | None = None,
35
+ ) -> Any: ...
36
+
37
+
38
+ @register_validator(name="nace/drex_policy", data_type="string")
39
+ class DrexPolicy(Validator):
40
+ """Validates a string by asking Drex one `noul` or `score` question about it.
41
+
42
+ Registered as `nace/drex_policy` for `string` values. It returns no fix value.
43
+
44
+ Args:
45
+ question (Noul | Score): The Drex question asked about the value.
46
+ fail_at (float): For a noul, the P(yes) threshold in [0, 1]. For a score,
47
+ the expected-index threshold.
48
+ fail_when (str): "yes" (default) or "no" for a noul. "at_or_above"
49
+ (default) or "below" for a score.
50
+ question_id (str): Key of the question in the Drex request. Default "policy".
51
+ model (str): Drex model id. Default "drex-v2.0". Images need "drex-max".
52
+ client (NaceClient): An existing client. Omit it to build one from
53
+ `NACE_API_KEY` and `NACE_BASE_URL`.
54
+ """
55
+
56
+ def __init__(
57
+ self,
58
+ question: Question,
59
+ fail_at: float,
60
+ fail_when: str | None = None,
61
+ question_id: str = "policy",
62
+ model: str | None = None,
63
+ client: SystemOneCaller | None = None,
64
+ on_fail: Callable | None = None,
65
+ **kwargs: Any,
66
+ ):
67
+ if not isinstance(question, (Noul, Score)):
68
+ raise TypeError("question must be a nace_sdk.Noul or nace_sdk.Score")
69
+ if not isinstance(question_id, str) or not question_id:
70
+ raise ValueError("question_id must be a non-empty string")
71
+ if isinstance(fail_at, bool) or not isinstance(fail_at, (int, float)):
72
+ raise TypeError("fail_at must be a number")
73
+ if model is not None and (not isinstance(model, str) or not model):
74
+ raise ValueError("model must be a non-empty string")
75
+ if isinstance(question, Noul):
76
+ fail_when = "yes" if fail_when is None else fail_when
77
+ if fail_when not in ("yes", "no"):
78
+ raise ValueError("noul fail_when must be 'yes' or 'no'")
79
+ if not 0.0 <= fail_at <= 1.0:
80
+ raise ValueError("noul fail_at must be between 0 and 1")
81
+ else:
82
+ fail_when = "at_or_above" if fail_when is None else fail_when
83
+ if fail_when not in ("at_or_above", "below"):
84
+ raise ValueError("score fail_when must be 'at_or_above' or 'below'")
85
+ if not math.isfinite(fail_at):
86
+ raise ValueError("score fail_at must be finite")
87
+ super().__init__(
88
+ on_fail=on_fail,
89
+ question=question.model_dump(mode="json"),
90
+ fail_at=fail_at,
91
+ fail_when=fail_when,
92
+ question_id=question_id,
93
+ model=model,
94
+ **kwargs,
95
+ )
96
+ self._question = question
97
+ self._fail_at = float(fail_at)
98
+ self._fail_when = fail_when
99
+ self._question_id = question_id
100
+ self._model = model
101
+ self._client = client
102
+
103
+ def _get_client(self) -> SystemOneCaller:
104
+ if self._client is None:
105
+ self._client = NaceClient(
106
+ client_info=CLIENT_INFO,
107
+ timeout=60,
108
+ retry=RetryPolicy(timeout=180),
109
+ )
110
+ return self._client
111
+
112
+ def _model_for(self, images: Sequence[str] | None) -> str:
113
+ if not images:
114
+ return self._model or DEFAULT_MODEL
115
+ if self._model != IMAGE_MODEL:
116
+ requested = self._model or DEFAULT_MODEL
117
+ raise DrexPolicyError(f"images require model {IMAGE_MODEL!r}, not {requested!r}")
118
+ if len(images) > MAX_IMAGES:
119
+ raise DrexPolicyError(f"{IMAGE_MODEL} accepts at most {MAX_IMAGES} images")
120
+ return IMAGE_MODEL
121
+
122
+ def validate(self, value: Any, metadata: dict[str, Any] | None = None) -> ValidationResult:
123
+ """Asks Drex the question about `value` and compares the answer with `fail_at`."""
124
+ if not isinstance(value, str):
125
+ raise TypeError("drex_policy validates a string")
126
+ images = (metadata or {}).get("images")
127
+ if images is not None and (
128
+ isinstance(images, str)
129
+ or not isinstance(images, (list, tuple))
130
+ or not all(isinstance(image, str) and image for image in images)
131
+ ):
132
+ raise TypeError("metadata['images'] must be a list of non-empty strings")
133
+ model = self._model_for(images)
134
+ response = self._get_client().system_one(
135
+ value,
136
+ {self._question_id: self._question},
137
+ model=model,
138
+ images=list(images) if images else None,
139
+ )
140
+ answer = _answer(response, self._question_id, self._question)
141
+ if isinstance(answer, NoulAnswer):
142
+ label, measured = "P(yes)", float(answer.noul)
143
+ fail_low = self._fail_when == "no"
144
+ else:
145
+ label, measured = "score", float(answer.score)
146
+ fail_low = self._fail_when == "below"
147
+ failed = measured < self._fail_at if fail_low else measured >= self._fail_at
148
+ result_metadata = {
149
+ "drex_model": model,
150
+ "drex_question_id": self._question_id,
151
+ "drex_answer": answer.model_dump(mode="json"),
152
+ }
153
+ if failed:
154
+ relation = "<" if fail_low else ">="
155
+ return FailResult(
156
+ error_message=f"Drex {label}={measured} {relation} fail_at={self._fail_at}",
157
+ metadata=result_metadata,
158
+ )
159
+ return PassResult(metadata=result_metadata)
160
+
161
+
162
+ def _answer(response: Any, question_id: str, question: Question) -> NoulAnswer | ScoreAnswer:
163
+ answers = getattr(response, "answers", None)
164
+ if not isinstance(answers, Mapping) or question_id not in answers:
165
+ raise DrexPolicyError(f"Drex response has no answer for {question_id!r}")
166
+ answer = answers[question_id]
167
+ expected = NoulAnswer if isinstance(question, Noul) else ScoreAnswer
168
+ if not isinstance(answer, expected):
169
+ raise DrexPolicyError(
170
+ f"Drex answer for {question_id!r} is {type(answer).__name__}, expected {expected.__name__}"
171
+ )
172
+ return answer
@@ -0,0 +1,190 @@
1
+ Metadata-Version: 2.4
2
+ Name: drex-policy
3
+ Version: 0.1.0
4
+ Summary: Guardrails validator that checks text, or text with images, against a Drex noul or score question.
5
+ Author-email: "Nace.AI" <alish@nace.ai>
6
+ License-Expression: MIT
7
+ Requires-Python: <4,>=3.10
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ Requires-Dist: guardrails-ai>=0.4.0
11
+ Requires-Dist: nace-sdk>=0.6.2
12
+ Provides-Extra: dev
13
+ Requires-Dist: pyright; extra == "dev"
14
+ Requires-Dist: pytest; extra == "dev"
15
+ Requires-Dist: ruff; extra == "dev"
16
+ Dynamic: license-file
17
+
18
+ # drex-policy
19
+
20
+ A [Guardrails](https://github.com/guardrails-ai/guardrails) validator that asks Drex, Nace.AI's decision model, one question about a string.
21
+
22
+ | Developed by | Nace.AI |
23
+ | --- | --- |
24
+ | Date of development | Oct 29, 2026 |
25
+ | Validator type | Moderation, policy |
26
+ | License | MIT |
27
+ | Input/Output | Input, Output |
28
+
29
+ ## What it checks
30
+
31
+ `DrexPolicy` asks [Drex](https://console.nace.ai/docs) one question about a string and fails when the answer crosses a threshold you set. Use it for checks such as PII present, off-topic, toxicity level, jailbreak attempt, or competitor mention.
32
+
33
+ The question is a `nace_sdk.Noul` (probability of a yes answer) or a `nace_sdk.Score` (expected index over criteria you list). Drex returns the probability. You choose the threshold with `fail_at`, preferably from labeled examples from your application.
34
+
35
+ - Noul: fails when P(yes) is at or above `fail_at`. With `fail_when="no"`, fails when P(yes) is below `fail_at`.
36
+ - Score: fails when the score is at or above `fail_at`. With `fail_when="below"`, fails when the score is below `fail_at`.
37
+
38
+ The Drex answer is in the result metadata on pass and on fail.
39
+
40
+ ## Models
41
+
42
+ The default model is `drex-v2.0`. Text-only checks can use any Drex model id, which is sent unchanged. `drex-latest` is sent as `drex-latest`. It is not `drex-max`.
43
+
44
+ To check images with the text, pass `model="drex-max"` and put the images in `metadata["images"]`. Each image is a URL or a data URL (PNG, JPEG, or WebP). `drex-max` accepts at most 8 images. If images are present and the model is not `drex-max`, or there are more than 8 images, the validator raises `DrexPolicyError` before it calls Drex. It does not drop the images.
45
+
46
+ ## Requirements
47
+
48
+ * Dependencies:
49
+ - guardrails-ai>=0.4.0
50
+ - nace-sdk>=0.6.2
51
+
52
+ * Access keys:
53
+ - `NACE_API_KEY`: a key from [console.nace.ai](https://console.nace.ai)
54
+ - `NACE_BASE_URL` (optional): defaults to `https://console.nace.ai`
55
+
56
+ When the validator builds its own client, each HTTP attempt times out after 60 seconds, the total retry budget is 180 seconds, and requests carry `X-Nace-Client: drex-policy/0.1.0`.
57
+
58
+ ## Install
59
+
60
+ ```bash
61
+ $ guardrails hub install hub://nace/drex_policy
62
+ ```
63
+
64
+ You can also install the package from PyPI:
65
+
66
+ ```bash
67
+ $ pip install drex-policy
68
+ ```
69
+
70
+ With a PyPI install, import the validator from `drex_policy` instead of `guardrails.hub`:
71
+
72
+ ```python
73
+ from drex_policy import DrexPolicy
74
+ ```
75
+
76
+ The examples below use the hub import. The class and its arguments are the same.
77
+
78
+ ## Examples
79
+
80
+ ### Check a string
81
+
82
+ ```python
83
+ from guardrails import Guard
84
+ from guardrails.hub import DrexPolicy
85
+ from nace_sdk import Noul
86
+
87
+ guard = Guard().use(
88
+ DrexPolicy(
89
+ question=Noul(instructions="Does the text contain a personal phone number or email address?"),
90
+ fail_at=0.5,
91
+ on_fail="exception",
92
+ )
93
+ )
94
+
95
+ guard.validate("Our office opens at 9am.") # Validator passes
96
+ guard.validate("Call me on +1 415 555 0100.") # Validator fails
97
+ ```
98
+
99
+ ### Check text with images
100
+
101
+ ```python
102
+ from guardrails import Guard
103
+ from guardrails.hub import DrexPolicy
104
+ from nace_sdk import Score
105
+
106
+ guard = Guard().use(
107
+ DrexPolicy(
108
+ question=Score(
109
+ instructions="How unsafe is this listing for a general audience?",
110
+ criteria=["safe", "borderline", "unsafe"],
111
+ ),
112
+ fail_at=1.5,
113
+ model="drex-max",
114
+ on_fail="exception",
115
+ )
116
+ )
117
+
118
+ guard.validate(
119
+ "Vintage camera, works well.",
120
+ metadata={"images": ["https://example.com/listing.jpg"]},
121
+ )
122
+ ```
123
+
124
+ ### Check one field of structured output
125
+
126
+ ```python
127
+ from pydantic import BaseModel, Field
128
+ from guardrails import Guard
129
+ from guardrails.hub import DrexPolicy
130
+ from nace_sdk import Noul
131
+
132
+ off_topic = DrexPolicy(
133
+ question=Noul(instructions="Is this reply about something other than our billing product?"),
134
+ fail_at=0.5,
135
+ on_fail="exception",
136
+ )
137
+
138
+
139
+ class Reply(BaseModel):
140
+ answer: str = Field(validators=[off_topic])
141
+
142
+
143
+ guard = Guard.for_pydantic(output_class=Reply)
144
+ guard.parse('{"answer": "Your invoice is in Settings > Billing."}')
145
+ ```
146
+
147
+ ## API reference
148
+
149
+ **`__init__(self, question, fail_at, fail_when=None, question_id="policy", model=None, client=None, on_fail=None)`**
150
+ <ul>
151
+
152
+ **Parameters**
153
+ - **`question`** *(nace_sdk.Noul | nace_sdk.Score)*: The question Drex answers about the value. If `instructions` is omitted, it stays `None` and is not sent.
154
+ - **`fail_at`** *(float)*: For a noul, a P(yes) threshold between 0 and 1. For a score, a finite expected-index threshold.
155
+ - **`fail_when`** *(str)*: `"yes"` (default) or `"no"` for a noul. `"at_or_above"` (default) or `"below"` for a score.
156
+ - **`question_id`** *(str)*: Key of the question in the Drex request and response. Default `"policy"`.
157
+ - **`model`** *(str)*: Drex model id. Default `drex-v2.0`. Images need `drex-max`.
158
+ - **`client`** *(nace_sdk.NaceClient)*: An existing client. If omitted, the validator builds one that reads `NACE_API_KEY` and `NACE_BASE_URL`.
159
+ - **`on_fail`** *(str, Callable)*: What Guardrails does on a fail: one of `exception`, `noop`, `reask`, `fix`, `filter`, `refrain` or `fix_reask`, or a function that Guardrails calls on a fail.
160
+ </ul>
161
+ <br/>
162
+
163
+ **`validate(self, value, metadata) -> ValidationResult`**
164
+ <ul>
165
+ Asks Drex the question about `value` and compares the answer with `fail_at`. Do not call it directly. `guard.parse(...)` and `guard.validate(...)` call it.
166
+
167
+ If Drex cannot answer (a network error, an API error, or a response without the expected answer), the exception propagates. The validator does not turn it into a pass or a fail.
168
+
169
+ **Parameters**
170
+ - **`value`** *(str)*: The text to check.
171
+ - **`metadata`** *(dict)*: Optional.
172
+
173
+ | Key | Type | Description | Default |
174
+ | --- | --- | --- | --- |
175
+ | `images` | list[str] | Image URLs or data URLs. Needs `model="drex-max"`; at most 8. | None |
176
+
177
+ **Result metadata**
178
+
179
+ | Key | Description |
180
+ | --- | --- |
181
+ | `drex_model` | Model id sent to Drex |
182
+ | `drex_question_id` | The `question_id` |
183
+ | `drex_answer` | The Drex answer, for example `{"type": "noul", "noul": 0.93}` |
184
+ </ul>
185
+
186
+ ## License
187
+
188
+ MIT. Copyright (c) 2026 Nace.AI. See [LICENSE](LICENSE).
189
+
190
+ Scaffolding from [guardrails-ai/validator-template](https://github.com/guardrails-ai/validator-template) (Apache-2.0).
@@ -0,0 +1,11 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ drex_policy/__init__.py
5
+ drex_policy/main.py
6
+ drex_policy.egg-info/PKG-INFO
7
+ drex_policy.egg-info/SOURCES.txt
8
+ drex_policy.egg-info/dependency_links.txt
9
+ drex_policy.egg-info/requires.txt
10
+ drex_policy.egg-info/top_level.txt
11
+ tests/test_validator.py
@@ -0,0 +1,7 @@
1
+ guardrails-ai>=0.4.0
2
+ nace-sdk>=0.6.2
3
+
4
+ [dev]
5
+ pyright
6
+ pytest
7
+ ruff
@@ -0,0 +1 @@
1
+ drex_policy
@@ -0,0 +1,29 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "drex-policy"
7
+ version = "0.1.0"
8
+ description = "Guardrails validator that checks text, or text with images, against a Drex noul or score question."
9
+ authors = [{ name = "Nace.AI", email = "alish@nace.ai" }]
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ readme = "README.md"
13
+ requires-python = ">=3.10,<4"
14
+ dependencies = [
15
+ "guardrails-ai>=0.4.0",
16
+ "nace-sdk>=0.6.2",
17
+ ]
18
+
19
+ [project.optional-dependencies]
20
+ dev = ["pyright", "pytest", "ruff"]
21
+
22
+ [tool.setuptools]
23
+ packages = ["drex_policy"]
24
+
25
+ [tool.pytest.ini_options]
26
+ testpaths = ["tests"]
27
+
28
+ [tool.pyright]
29
+ include = ["drex_policy"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,161 @@
1
+ import json
2
+ from typing import Any
3
+
4
+ import pytest
5
+ from guardrails import Guard
6
+ from nace_sdk import Noul, RetryPolicy, Score, SystemOneResponse
7
+
8
+ from drex_policy import DrexPolicy
9
+ from drex_policy.main import CLIENT_INFO, DrexPolicyError
10
+
11
+
12
+ class FakeClient:
13
+ def __init__(self, answer: dict) -> None:
14
+ self.answer = answer
15
+ self.calls: list[Any] = []
16
+
17
+ def system_one(self, state, questions, *, model=None, images=None):
18
+ self.calls.append((state, questions, model, images))
19
+ return SystemOneResponse.model_validate_json(
20
+ json.dumps(
21
+ {
22
+ "model": model,
23
+ "usage": {"input_tokens": 4, "image_tokens": 0, "output_tokens": 1},
24
+ "answers": {"policy": self.answer},
25
+ }
26
+ )
27
+ )
28
+
29
+
30
+ def noul(probability: float) -> FakeClient:
31
+ return FakeClient({"type": "noul", "noul": probability})
32
+
33
+
34
+ def score(value: float) -> FakeClient:
35
+ return FakeClient(
36
+ {
37
+ "type": "score",
38
+ "score": value,
39
+ "confidence": 0.8,
40
+ "legend": ["none", "low", "high"],
41
+ "probabilities": {"0": 0.1, "1": 0.2, "2": 0.7},
42
+ }
43
+ )
44
+
45
+
46
+ TOXIC = Noul(instructions="Is this text toxic?")
47
+
48
+
49
+ def guard(client: FakeClient, model: str | None = None) -> Guard:
50
+ return Guard().use(
51
+ DrexPolicy(question=TOXIC, fail_at=0.5, model=model, client=client, on_fail="exception")
52
+ )
53
+
54
+
55
+ def test_pass():
56
+ client = noul(0.1)
57
+ result = guard(client).validate("hello")
58
+ assert result.validation_passed is True
59
+ assert result.validated_output == "hello"
60
+ assert client.calls == [("hello", {"policy": TOXIC}, "drex-v2.0", None)]
61
+
62
+
63
+ def test_fail():
64
+ with pytest.raises(Exception) as exc_info:
65
+ guard(noul(0.9)).validate("you idiot")
66
+ assert str(exc_info.value) == (
67
+ "Validation failed for field with errors: Drex P(yes)=0.9 >= fail_at=0.5"
68
+ )
69
+
70
+
71
+ def test_fail_when_no():
72
+ validator = DrexPolicy(question=TOXIC, fail_at=0.5, fail_when="no", client=noul(0.1))
73
+ result = validator.validate("hello", {})
74
+ assert result.outcome == "fail"
75
+ assert result.error_message == "Drex P(yes)=0.1 < fail_at=0.5"
76
+
77
+
78
+ def test_fail_keeps_answer_in_metadata():
79
+ validator = DrexPolicy(question=TOXIC, fail_at=0.5, client=noul(0.9))
80
+ result = validator.validate("you idiot", {})
81
+ assert result.outcome == "fail"
82
+ assert result.metadata == {
83
+ "drex_model": "drex-v2.0",
84
+ "drex_question_id": "policy",
85
+ "drex_answer": {"type": "noul", "noul": 0.9},
86
+ }
87
+
88
+
89
+ def test_score_at_or_above_and_below():
90
+ question = Score(instructions="How toxic?", criteria=["none", "low", "high"])
91
+ high = DrexPolicy(question=question, fail_at=1.5, client=score(2.0))
92
+ assert high.validate("x", {}).outcome == "fail"
93
+ below = DrexPolicy(question=question, fail_at=1.5, fail_when="below", client=score(2.0))
94
+ assert below.validate("x", {}).outcome == "pass"
95
+
96
+
97
+ def test_drex_latest_is_forwarded_unchanged():
98
+ client = noul(0.1)
99
+ guard(client, model="drex-latest").validate("hello")
100
+ assert client.calls[0][2] == "drex-latest"
101
+
102
+
103
+ def test_images_go_to_drex_max():
104
+ client = noul(0.1)
105
+ validator = DrexPolicy(question=TOXIC, fail_at=0.5, model="drex-max", client=client)
106
+ validator.validate("caption", {"images": ["data:image/png;base64,AAAA"]})
107
+ assert client.calls[0][2:] == ("drex-max", ["data:image/png;base64,AAAA"])
108
+
109
+
110
+ @pytest.mark.parametrize("model", [None, "drex-v2.0", "drex-latest"])
111
+ def test_images_without_drex_max_are_refused(model):
112
+ client = noul(0.1)
113
+ validator = DrexPolicy(question=TOXIC, fail_at=0.5, model=model, client=client)
114
+ with pytest.raises(DrexPolicyError, match="images require model 'drex-max'"):
115
+ validator.validate("caption", {"images": ["data:image/png;base64,AAAA"]})
116
+ assert client.calls == []
117
+
118
+
119
+ def test_more_than_eight_images_are_refused():
120
+ client = noul(0.1)
121
+ validator = DrexPolicy(question=TOXIC, fail_at=0.5, model="drex-max", client=client)
122
+ with pytest.raises(DrexPolicyError, match="at most 8 images"):
123
+ validator.validate("caption", {"images": ["data:image/png;base64,AAAA"] * 9})
124
+ assert client.calls == []
125
+
126
+
127
+ def test_omitted_instructions_stay_none():
128
+ client = noul(0.1)
129
+ DrexPolicy(question=Noul(), fail_at=0.5, client=client).validate("hello", {})
130
+ sent = client.calls[0][1]["policy"]
131
+ assert sent.instructions is None
132
+ assert sent.model_dump(exclude_none=True) == {"type": "noul"}
133
+
134
+
135
+ def test_built_client_settings(monkeypatch):
136
+ seen = {}
137
+
138
+ class Recorder:
139
+ def __init__(self, **kwargs):
140
+ seen.update(kwargs)
141
+
142
+ monkeypatch.setattr("drex_policy.main.NaceClient", Recorder)
143
+ DrexPolicy(question=TOXIC, fail_at=0.5)._get_client()
144
+ assert seen["client_info"] == CLIENT_INFO == "drex-policy/0.1.0"
145
+ assert seen["timeout"] == 60
146
+ assert seen["retry"] == RetryPolicy(timeout=180)
147
+ assert "model" not in seen
148
+
149
+
150
+ @pytest.mark.parametrize(
151
+ "kwargs",
152
+ [
153
+ {"fail_at": 1.5},
154
+ {"fail_at": 0.5, "fail_when": "below"},
155
+ {"fail_at": 0.5, "question_id": ""},
156
+ {"fail_at": 0.5, "model": ""},
157
+ ],
158
+ )
159
+ def test_bad_config(kwargs):
160
+ with pytest.raises(ValueError):
161
+ DrexPolicy(question=TOXIC, **kwargs)