diff --git a/README.md b/README.md index df2ccd2131..17bc0bba48 100644 --- a/README.md +++ b/README.md @@ -295,9 +295,9 @@ Evaluating the safety of a LLM-based conversational application is a complex tas ## How is this different? -There are many ways guardrails can be added to an LLM-based conversational application. For example: explicit moderation endpoints (e.g., OpenAI, ActiveFence), critique chains (e.g. constitutional chain), parsing the output (e.g. guardrails.ai), individual guardrails (e.g., LLM-Guard), hallucination detection for RAG applications (e.g., Got It AI, Patronus Lynx). +There are many ways guardrails can be added to an LLM-based conversational application. For example: explicit moderation endpoints (e.g., OpenAI, ActiveFence, PolicyAI), critique chains (e.g. constitutional chain), parsing the output (e.g. guardrails.ai), individual guardrails (e.g., LLM-Guard), hallucination detection for RAG applications (e.g., Got It AI, Patronus Lynx). -NeMo Guardrails aims to provide a flexible toolkit that can integrate all these complementary approaches into a cohesive LLM guardrails layer. For example, the toolkit provides out-of-the-box integration with ActiveFence, AlignScore and LangChain chains. +NeMo Guardrails aims to provide a flexible toolkit that can integrate all these complementary approaches into a cohesive LLM guardrails layer. For example, the toolkit provides out-of-the-box integration with ActiveFence, PolicyAI, AlignScore and LangChain chains. To the best of our knowledge, NeMo Guardrails is the only guardrails toolkit that also offers a solution for modeling the dialog between the user and the LLM. This enables on one hand the ability to guide the dialog in a precise way. On the other hand it enables fine-grained control for when certain guardrails should be used, e.g., use fact-checking only for certain types of questions. diff --git a/docs/configure-rails/guardrail-catalog/community/policyai.md b/docs/configure-rails/guardrail-catalog/community/policyai.md new file mode 100644 index 0000000000..684f6891ae --- /dev/null +++ b/docs/configure-rails/guardrail-catalog/community/policyai.md @@ -0,0 +1,116 @@ +# PolicyAI Integration + +NeMo Guardrails supports using the [PolicyAI](https://musubilabs.ai) content moderation API as an input and output rail out-of-the-box (you need to have the `POLICYAI_API_KEY` environment variable set). + +PolicyAI provides flexible policy-based content moderation, allowing you to define custom policies for your specific use cases and manage them through tags. + +## Setup + +1. Sign up for a PolicyAI account at [musubilabs.ai](https://musubilabs.ai) +2. Create your policies and organize them with tags +3. Set the required environment variables: + +```bash +export POLICYAI_API_KEY="your-api-key" +export POLICYAI_BASE_URL="https://api.musubilabs.ai" # Optional, this is the default +export POLICYAI_TAG_NAME="prod" # Optional, defaults to "prod" +``` + +## Usage + +### Basic Input Moderation + +```yaml +rails: + input: + flows: + - policyai moderation on input +``` + +### Basic Output Moderation + +```yaml +rails: + output: + flows: + - policyai moderation on output +``` + +### Using Different Tags + +To use different policy tags for different environments, set the `POLICYAI_TAG_NAME` environment variable: + +```bash +# For staging environment +export POLICYAI_TAG_NAME="staging" + +# For production environment +export POLICYAI_TAG_NAME="prod" +``` + +## Complete Example + +```yaml +models: + - type: main + engine: openai + model: gpt-4 + +rails: + input: + flows: + - policyai moderation on input + + output: + flows: + - policyai moderation on output +``` + +## How It Works + +1. **Input Rails**: When a user sends a message, PolicyAI evaluates it against all policies attached to the configured tag. If any policy returns `UNSAFE`, the message is blocked. + +2. **Output Rails**: Before the bot's response is sent to the user, PolicyAI evaluates it. If the content violates any policy, the response is replaced with a refusal message. + +## Response Format + +PolicyAI returns the following information for each evaluation: + +- `assessment`: `"SAFE"` or `"UNSAFE"` +- `category`: The category of violation (if UNSAFE) +- `severity`: Severity level from 0 (safe) to 3 (high severity) +- `reason`: Human-readable explanation + +## Customizing Behavior + +To customize the behavior when content is flagged, you can override the default flows in your config: + +```text +define subflow policyai moderation on input + """Custom PolicyAI input moderation.""" + $result = execute call_policyai_api(text=$user_message) + + if $result.assessment == "UNSAFE" + bot inform content policy violation + stop + +define bot inform content policy violation + "I'm sorry, but I cannot process that request. Please rephrase your message." +``` + +## Environment Variables + +| Variable | Required | Default | Description | +|----------|----------|---------|-------------| +| `POLICYAI_API_KEY` | Yes | - | Your PolicyAI API key | +| `POLICYAI_BASE_URL` | No | `https://api.musubilabs.ai` | PolicyAI API base URL | +| `POLICYAI_TAG_NAME` | No | `prod` | Default policy tag to use | + +## Error Handling + +If the PolicyAI API is unavailable or returns an error, the action will raise an exception. To implement fail-open or fail-closed behavior, you can wrap the action in a try-catch block in your custom flows. + +## Learn More + +- [PolicyAI Documentation](https://docs.musubilabs.ai) +- [Musubi Labs](https://musubilabs.ai) diff --git a/docs/configure-rails/guardrail-catalog/third-party.md b/docs/configure-rails/guardrail-catalog/third-party.md index 4fc9175f13..ba3b829756 100644 --- a/docs/configure-rails/guardrail-catalog/third-party.md +++ b/docs/configure-rails/guardrail-catalog/third-party.md @@ -34,6 +34,26 @@ rails: For more details, check out the [ActiveFence Integration](community/active-fence.md) page. +## PolicyAI + +The NeMo Guardrails library supports using [PolicyAI](https://musubilabs.ai) by Musubi Labs as an input and output rail out-of-the-box (you need to have the `POLICYAI_API_KEY` environment variable set). + +PolicyAI provides policy-based content moderation, allowing you to define custom policies and organize them with tags for environment-based management. + +### Example usage + +```yaml +rails: + input: + flows: + - policyai moderation on input + output: + flows: + - policyai moderation on output +``` + +For more details, check out the [PolicyAI Integration](community/policyai.md) page. + ## AutoAlign The NeMo Guardrails library supports using the AutoAlign's guardrails API (you need to have the `AUTOALIGN_API_KEY` environment variable set). @@ -283,6 +303,7 @@ Llama Guard Pangea AI Guard Patronus Evaluate API Patronus Lynx +PolicyAI Presidio Private AI Prompt Security diff --git a/nemoguardrails/library/policyai/__init__.py b/nemoguardrails/library/policyai/__init__.py new file mode 100644 index 0000000000..6c7f64065d --- /dev/null +++ b/nemoguardrails/library/policyai/__init__.py @@ -0,0 +1,14 @@ +# SPDX-FileCopyrightText: Copyright (c) 2023-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/nemoguardrails/library/policyai/actions.py b/nemoguardrails/library/policyai/actions.py new file mode 100644 index 0000000000..60ce3e8380 --- /dev/null +++ b/nemoguardrails/library/policyai/actions.py @@ -0,0 +1,159 @@ +# SPDX-FileCopyrightText: Copyright (c) 2023-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +PolicyAI Integration for NeMo Guardrails. + +PolicyAI provides content moderation and policy enforcement capabilities +for LLM applications. This integration allows using PolicyAI as an input +and output rail for content moderation. + +For more information, see: https://musubilabs.ai +""" + +import json +import logging +import os +from typing import Optional + +import aiohttp + +from nemoguardrails.actions import action + +log = logging.getLogger(__name__) + + +def call_policyai_api_mapping(result: dict) -> bool: + """ + Mapping for call_policyai_api. + + Expects result to be a dict with: + - "assessment": "SAFE" or "UNSAFE" + - "category": the violation category (if UNSAFE) + - "severity": severity level 0-3 + + Block (return True) if: + 1. Assessment is "UNSAFE" + """ + assessment = result.get("assessment", "SAFE") + return assessment == "UNSAFE" + + +@action(is_system_action=True, output_mapping=call_policyai_api_mapping) +async def call_policyai_api( + text: Optional[str] = None, + tag_name: Optional[str] = None, + **kwargs, +): + """ + Call the PolicyAI API to evaluate content. + + Args: + text: The text content to evaluate. + tag_name: Optional tag name for the PolicyAI evaluation. + If not provided, uses POLICYAI_TAG_NAME env var or "prod". + + Returns: + dict with: + - assessment: "SAFE" or "UNSAFE" + - category: the violation category (if UNSAFE) + - severity: severity level 0-3 + - reason: explanation for the decision + """ + api_key = os.environ.get("POLICYAI_API_KEY") + + if api_key is None: + raise ValueError("POLICYAI_API_KEY environment variable not set.") + + base_url = os.environ.get("POLICYAI_BASE_URL", "https://api.musubilabs.ai") + base_url = base_url.rstrip("/") + + # Get tag name from parameter, env var, or default + if tag_name is None: + tag_name = os.environ.get("POLICYAI_TAG_NAME", "prod") + + url = f"{base_url}/policyai/v1/decisions/evaluate/{tag_name}" + + headers = { + "Musubi-Api-Key": api_key, + "Content-Type": "application/json", + } + + data = { + "content": [ + { + "type": "TEXT", + "content": text, + } + ], + } + + timeout = aiohttp.ClientTimeout(total=30) + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.post( + url=url, + headers=headers, + json=data, + ) as response: + if response.status != 200: + raise ValueError( + f"PolicyAI call failed with status code {response.status}.\nDetails: {await response.text()}" + ) + response_json = await response.json() + log.info(json.dumps(response_json, indent=2)) + + # PolicyAI returns results in "data" array for tag-based evaluation + results = response_json.get("data", []) + + # Fail-closed: If no policies are attached to the tag, raise an error + # rather than silently allowing content through + if not results: + raise ValueError( + f"PolicyAI returned no policy results for tag '{tag_name}'. " + "Ensure policies are attached to this tag." + ) + + # Check if all policies failed evaluation + successful_results = [r for r in results if r.get("status") != "failed"] + if not successful_results: + raise ValueError( + f"All PolicyAI policy evaluations failed for tag '{tag_name}'. Check policy configurations." + ) + + # Aggregate results - if ANY policy returns UNSAFE, overall is UNSAFE + overall_assessment = "SAFE" + triggered_category = "Safe" + max_severity = 0 + reason = "Content passed all policy checks" + + for result in successful_results: + assessment = result.get("assessment", "SAFE") + if assessment == "UNSAFE": + overall_assessment = "UNSAFE" + triggered_category = result.get("category", "Unknown") + max_severity = max(max_severity, result.get("severity", 0)) + reason = result.get("reason", "Policy violation detected") + break # Stop at first UNSAFE result + + # Pre-format exception message for Colang 1.x compatibility + # (Colang 1.x doesn't support string concatenation in create event) + exception_message = f"PolicyAI moderation triggered. Content violated policy: {triggered_category}" + + return { + "assessment": overall_assessment, + "category": triggered_category, + "severity": max_severity, + "reason": reason, + "exception_message": exception_message, + } diff --git a/nemoguardrails/library/policyai/flows.co b/nemoguardrails/library/policyai/flows.co new file mode 100644 index 0000000000..f892cb9fe5 --- /dev/null +++ b/nemoguardrails/library/policyai/flows.co @@ -0,0 +1,33 @@ +""" +PolicyAI Integration Flows (Colang 2.x) + +PolicyAI provides content moderation and policy enforcement capabilities. +For more information, see: https://musubilabs.ai + +Supported features: +- Input moderation: Check user messages against configured policies +- Output moderation: Check bot responses against configured policies +- Tag-based evaluation: Use POLICYAI_TAG_NAME env var to specify policy tag +""" + +flow policyai moderation on input + """Guardrail based on PolicyAI assessment.""" + $result = await CallPolicyaiApiAction(text=$user_message) + + if $result.assessment == "UNSAFE" + if $system.config.enable_rails_exceptions + send PolicyAIModerationRailException(message="PolicyAI moderation triggered. Content violated policy: " + $result.category) + else + bot refuse to respond + abort + +flow policyai moderation on output + """Guardrail based on PolicyAI assessment.""" + $result = await CallPolicyaiApiAction(text=$bot_message) + + if $result.assessment == "UNSAFE" + if $system.config.enable_rails_exceptions + send PolicyAIModerationRailException(message="PolicyAI moderation triggered. Content violated policy: " + $result.category) + else + bot refuse to respond + abort diff --git a/nemoguardrails/library/policyai/flows.v1.co b/nemoguardrails/library/policyai/flows.v1.co new file mode 100644 index 0000000000..259cd4916e --- /dev/null +++ b/nemoguardrails/library/policyai/flows.v1.co @@ -0,0 +1,33 @@ +""" +PolicyAI Integration Flows (Colang 1.x) + +PolicyAI provides content moderation and policy enforcement capabilities. +For more information, see: https://musubilabs.ai + +Supported features: +- Input moderation: Check user messages against configured policies +- Output moderation: Check bot responses against configured policies +- Tag-based evaluation: Use POLICYAI_TAG_NAME env var to specify policy tag +""" + +define subflow policyai moderation on input + """Guardrail based on PolicyAI assessment.""" + $result = execute call_policyai_api(text=$user_message) + + if $result.assessment == "UNSAFE" + if $config.enable_rails_exceptions + create event PolicyAIModerationRailException(message=$result.exception_message) + else + bot refuse to respond + stop + +define subflow policyai moderation on output + """Guardrail based on PolicyAI assessment.""" + $result = execute call_policyai_api(text=$bot_message) + + if $result.assessment == "UNSAFE" + if $config.enable_rails_exceptions + create event PolicyAIModerationRailException(message=$result.exception_message) + else + bot refuse to respond + stop diff --git a/tests/test_policyai_rail.py b/tests/test_policyai_rail.py new file mode 100644 index 0000000000..10d3dfc365 --- /dev/null +++ b/tests/test_policyai_rail.py @@ -0,0 +1,641 @@ +# SPDX-FileCopyrightText: Copyright (c) 2023-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest +from aioresponses import aioresponses + +from nemoguardrails import RailsConfig +from nemoguardrails.library.policyai.actions import call_policyai_api +from tests.utils import TestChat + + +def test_input_safe(monkeypatch): + """Test that safe input is allowed through.""" + monkeypatch.setenv("POLICYAI_API_KEY", "test-api-key") + monkeypatch.setenv("POLICYAI_TAG_NAME", "test") + + config = RailsConfig.from_content( + colang_content=""" + define user express greeting + "hi" + + define flow + user express greeting + bot express greeting + + define bot express greeting + "Hello! How can I assist you today?" + """, + yaml_content=""" + models: + - type: main + engine: openai + model: gpt-3.5-turbo-instruct + + rails: + input: + flows: + - policyai moderation on input + """, + ) + chat = TestChat( + config, + llm_completions=[ + " express greeting", + ], + ) + + with aioresponses() as m: + # PolicyAI returns SAFE assessment + m.post( + "https://api.musubilabs.ai/policyai/v1/decisions/evaluate/test", + payload={ + "data": [ + { + "status": "success", + "assessment": "SAFE", + "category": "Safe", + "severity": 0, + "reason": "Content is safe", + } + ] + }, + ) + + chat >> "Hello!" + chat << "Hello! How can I assist you today?" + + +def test_input_unsafe(monkeypatch): + """Test that unsafe input is blocked.""" + monkeypatch.setenv("POLICYAI_API_KEY", "test-api-key") + monkeypatch.setenv("POLICYAI_TAG_NAME", "test") + + config = RailsConfig.from_content( + colang_content=""" + define user express greeting + "hi" + + define flow + user express greeting + bot express greeting + + define bot express greeting + "Hello! How can I assist you today?" + """, + yaml_content=""" + models: + - type: main + engine: openai + model: gpt-3.5-turbo-instruct + + rails: + input: + flows: + - policyai moderation on input + """, + ) + chat = TestChat( + config, + llm_completions=[ + " express greeting", + ], + ) + + with aioresponses() as m: + # PolicyAI returns UNSAFE assessment + m.post( + "https://api.musubilabs.ai/policyai/v1/decisions/evaluate/test", + payload={ + "data": [ + { + "status": "success", + "assessment": "UNSAFE", + "category": "HarmfulContent", + "severity": 2, + "reason": "Content contains harmful language", + } + ] + }, + ) + + chat >> "some harmful content" + chat << "I'm sorry, I can't respond to that." + + +def test_output_safe(monkeypatch): + """Test that safe output is allowed through.""" + monkeypatch.setenv("POLICYAI_API_KEY", "test-api-key") + monkeypatch.setenv("POLICYAI_TAG_NAME", "test") + + config = RailsConfig.from_content( + yaml_content=""" + models: + - type: main + engine: openai + model: gpt-3.5-turbo-instruct + + rails: + output: + flows: + - policyai moderation on output + """, + ) + chat = TestChat( + config, + llm_completions=[ + " Hello! How can I help you today?", + ], + ) + + with aioresponses() as m: + # PolicyAI returns SAFE assessment + m.post( + "https://api.musubilabs.ai/policyai/v1/decisions/evaluate/test", + payload={ + "data": [ + { + "status": "success", + "assessment": "SAFE", + "category": "Safe", + "severity": 0, + "reason": "Content is safe", + } + ] + }, + ) + + chat >> "Hello!" + chat << "Hello! How can I help you today?" + + +def test_output_unsafe(monkeypatch): + """Test that unsafe output is blocked.""" + monkeypatch.setenv("POLICYAI_API_KEY", "test-api-key") + monkeypatch.setenv("POLICYAI_TAG_NAME", "test") + + config = RailsConfig.from_content( + yaml_content=""" + models: + - type: main + engine: openai + model: gpt-3.5-turbo-instruct + + rails: + output: + flows: + - policyai moderation on output + """, + ) + chat = TestChat( + config, + llm_completions=[ + " I promise you a full refund of $500!", + ], + ) + + with aioresponses() as m: + # PolicyAI returns UNSAFE assessment (e.g., unauthorized refund promise) + m.post( + "https://api.musubilabs.ai/policyai/v1/decisions/evaluate/test", + payload={ + "data": [ + { + "status": "success", + "assessment": "UNSAFE", + "category": "UnauthorizedCommitment", + "severity": 2, + "reason": "AI made unauthorized commitment about refund", + } + ] + }, + ) + + chat >> "Can I get a refund?" + chat << "I'm sorry, I can't respond to that." + + +def test_custom_tag_via_env(monkeypatch): + """Test using a custom policy tag via environment variable.""" + monkeypatch.setenv("POLICYAI_API_KEY", "test-api-key") + monkeypatch.setenv("POLICYAI_TAG_NAME", "custom-tag") + + config = RailsConfig.from_content( + colang_content=""" + define user express greeting + "hi" + + define flow + user express greeting + bot express greeting + + define bot express greeting + "Hello! How can I assist you today?" + """, + yaml_content=""" + models: + - type: main + engine: openai + model: gpt-3.5-turbo-instruct + + rails: + input: + flows: + - policyai moderation on input + """, + ) + chat = TestChat( + config, + llm_completions=[ + " express greeting", + ], + ) + + with aioresponses() as m: + # Note: The URL should use the custom tag from env var + m.post( + "https://api.musubilabs.ai/policyai/v1/decisions/evaluate/custom-tag", + payload={ + "data": [ + { + "status": "success", + "assessment": "SAFE", + "category": "Safe", + "severity": 0, + "reason": "Content is safe", + } + ] + }, + ) + + chat >> "Hello!" + chat << "Hello! How can I assist you today?" + + +def test_multiple_policies(monkeypatch): + """Test when multiple policies are evaluated (tag has multiple policies).""" + monkeypatch.setenv("POLICYAI_API_KEY", "test-api-key") + monkeypatch.setenv("POLICYAI_TAG_NAME", "test") + + config = RailsConfig.from_content( + yaml_content=""" + models: + - type: main + engine: openai + model: gpt-3.5-turbo-instruct + + rails: + output: + flows: + - policyai moderation on output + """, + ) + chat = TestChat( + config, + llm_completions=[ + " Here's some content", + ], + ) + + with aioresponses() as m: + # Multiple policies - first is SAFE, second is UNSAFE + m.post( + "https://api.musubilabs.ai/policyai/v1/decisions/evaluate/test", + payload={ + "data": [ + { + "status": "success", + "assessment": "SAFE", + "category": "ToxicityCheck", + "severity": 0, + "reason": "No toxic content detected", + }, + { + "status": "success", + "assessment": "UNSAFE", + "category": "PIIDetection", + "severity": 1, + "reason": "PII detected in content", + }, + ] + }, + ) + + chat >> "Hello!" + # Should be blocked because one policy returned UNSAFE + chat << "I'm sorry, I can't respond to that." + + +@pytest.mark.asyncio +async def test_empty_data_array_raises_error(monkeypatch): + """Test that empty data array (no policies attached to tag) raises an error.""" + monkeypatch.setenv("POLICYAI_API_KEY", "test-api-key") + monkeypatch.setenv("POLICYAI_TAG_NAME", "empty-tag") + + with aioresponses() as m: + # PolicyAI returns empty data array (no policies attached to tag) + m.post( + "https://api.musubilabs.ai/policyai/v1/decisions/evaluate/empty-tag", + payload={"data": []}, + ) + + with pytest.raises(ValueError) as exc_info: + await call_policyai_api(text="Hello!") + + assert "no policy results" in str(exc_info.value).lower() + assert "empty-tag" in str(exc_info.value) + + +@pytest.mark.asyncio +async def test_api_error_raises_exception(monkeypatch): + """Test that API errors (non-200 status) raise an exception.""" + monkeypatch.setenv("POLICYAI_API_KEY", "test-api-key") + monkeypatch.setenv("POLICYAI_TAG_NAME", "test") + + with aioresponses() as m: + # PolicyAI returns 500 error + m.post( + "https://api.musubilabs.ai/policyai/v1/decisions/evaluate/test", + status=500, + body="Internal Server Error", + ) + + with pytest.raises(ValueError) as exc_info: + await call_policyai_api(text="Hello!") + + assert "500" in str(exc_info.value) + + +@pytest.mark.asyncio +async def test_all_policies_failed_raises_error(monkeypatch): + """Test that all policies failing raises an error.""" + monkeypatch.setenv("POLICYAI_API_KEY", "test-api-key") + monkeypatch.setenv("POLICYAI_TAG_NAME", "failing-tag") + + with aioresponses() as m: + # All policies return failed status + m.post( + "https://api.musubilabs.ai/policyai/v1/decisions/evaluate/failing-tag", + payload={ + "data": [ + { + "status": "failed", + "error": "Policy configuration error", + }, + { + "status": "failed", + "error": "Policy timeout", + }, + ] + }, + ) + + with pytest.raises(ValueError) as exc_info: + await call_policyai_api(text="Hello!") + + assert "all" in str(exc_info.value).lower() + assert "failed" in str(exc_info.value).lower() + + +@pytest.mark.asyncio +async def test_missing_api_key_raises_error(monkeypatch): + """Test that missing API key raises an error.""" + # Ensure POLICYAI_API_KEY is not set + monkeypatch.delenv("POLICYAI_API_KEY", raising=False) + + with pytest.raises(ValueError) as exc_info: + await call_policyai_api(text="Hello!") + + assert "POLICYAI_API_KEY" in str(exc_info.value) + + +@pytest.mark.asyncio +async def test_empty_text_parameter(monkeypatch): + """Test handling of empty text parameter.""" + monkeypatch.setenv("POLICYAI_API_KEY", "test-api-key") + monkeypatch.setenv("POLICYAI_TAG_NAME", "test") + + with aioresponses() as m: + # PolicyAI should still process empty/None text + m.post( + "https://api.musubilabs.ai/policyai/v1/decisions/evaluate/test", + payload={ + "data": [ + { + "status": "success", + "assessment": "SAFE", + "category": "Safe", + "severity": 0, + "reason": "Empty content is safe", + } + ] + }, + ) + + # Test with empty string + result = await call_policyai_api(text="") + assert result["assessment"] == "SAFE" + + +@pytest.mark.asyncio +async def test_none_text_parameter(monkeypatch): + """Test handling of None text parameter.""" + monkeypatch.setenv("POLICYAI_API_KEY", "test-api-key") + monkeypatch.setenv("POLICYAI_TAG_NAME", "test") + + with aioresponses() as m: + # PolicyAI should still process None text + m.post( + "https://api.musubilabs.ai/policyai/v1/decisions/evaluate/test", + payload={ + "data": [ + { + "status": "success", + "assessment": "SAFE", + "category": "Safe", + "severity": 0, + "reason": "Null content is safe", + } + ] + }, + ) + + # Test with None + result = await call_policyai_api(text=None) + assert result["assessment"] == "SAFE" + + +@pytest.mark.asyncio +async def test_partial_policy_failures(monkeypatch): + """Test that partial policy failures still work if some succeed.""" + monkeypatch.setenv("POLICYAI_API_KEY", "test-api-key") + monkeypatch.setenv("POLICYAI_TAG_NAME", "test") + + with aioresponses() as m: + # Some policies fail, but one succeeds with SAFE + m.post( + "https://api.musubilabs.ai/policyai/v1/decisions/evaluate/test", + payload={ + "data": [ + { + "status": "failed", + "error": "Policy timeout", + }, + { + "status": "success", + "assessment": "SAFE", + "category": "ToxicityCheck", + "severity": 0, + "reason": "No toxic content", + }, + ] + }, + ) + + result = await call_policyai_api(text="Hello!") + assert result["assessment"] == "SAFE" + + +@pytest.mark.asyncio +async def test_custom_base_url_with_trailing_slash(monkeypatch): + """Test that custom base URL with trailing slash is handled correctly.""" + monkeypatch.setenv("POLICYAI_API_KEY", "test-api-key") + monkeypatch.setenv("POLICYAI_BASE_URL", "https://custom.api.example.com/") + monkeypatch.setenv("POLICYAI_TAG_NAME", "test") + + with aioresponses() as m: + # URL should have trailing slash stripped + m.post( + "https://custom.api.example.com/policyai/v1/decisions/evaluate/test", + payload={ + "data": [ + { + "status": "success", + "assessment": "SAFE", + "category": "Safe", + "severity": 0, + "reason": "Content is safe", + } + ] + }, + ) + + result = await call_policyai_api(text="Hello!") + assert result["assessment"] == "SAFE" + + +@pytest.mark.asyncio +async def test_tag_name_parameter_overrides_env(monkeypatch): + """Test that tag_name parameter overrides environment variable.""" + monkeypatch.setenv("POLICYAI_API_KEY", "test-api-key") + monkeypatch.setenv("POLICYAI_TAG_NAME", "env-tag") + + with aioresponses() as m: + # Should use parameter tag, not env var tag + m.post( + "https://api.musubilabs.ai/policyai/v1/decisions/evaluate/param-tag", + payload={ + "data": [ + { + "status": "success", + "assessment": "SAFE", + "category": "Safe", + "severity": 0, + "reason": "Content is safe", + } + ] + }, + ) + + result = await call_policyai_api(text="Hello!", tag_name="param-tag") + assert result["assessment"] == "SAFE" + + +@pytest.mark.asyncio +async def test_unsafe_with_missing_fields(monkeypatch): + """Test UNSAFE response with missing optional fields uses defaults.""" + monkeypatch.setenv("POLICYAI_API_KEY", "test-api-key") + monkeypatch.setenv("POLICYAI_TAG_NAME", "test") + + with aioresponses() as m: + # UNSAFE response without category, severity, or reason + m.post( + "https://api.musubilabs.ai/policyai/v1/decisions/evaluate/test", + payload={ + "data": [ + { + "status": "success", + "assessment": "UNSAFE", + } + ] + }, + ) + + result = await call_policyai_api(text="Bad content") + assert result["assessment"] == "UNSAFE" + assert result["category"] == "Unknown" + assert result["severity"] == 0 + assert result["reason"] == "Policy violation detected" + + +def test_mapping_function_safe(): + """Test the output mapping function returns False for SAFE.""" + from nemoguardrails.library.policyai.actions import call_policyai_api_mapping + + result = call_policyai_api_mapping({"assessment": "SAFE"}) + assert result is False + + +def test_mapping_function_unsafe(): + """Test the output mapping function returns True for UNSAFE.""" + from nemoguardrails.library.policyai.actions import call_policyai_api_mapping + + result = call_policyai_api_mapping({"assessment": "UNSAFE"}) + assert result is True + + +def test_mapping_function_missing_assessment(): + """Test the output mapping function defaults to SAFE when assessment is missing.""" + from nemoguardrails.library.policyai.actions import call_policyai_api_mapping + + result = call_policyai_api_mapping({}) + assert result is False + + +@pytest.mark.asyncio +async def test_default_tag_name_prod(monkeypatch): + """Test that default tag 'prod' is used when env var is not set.""" + monkeypatch.setenv("POLICYAI_API_KEY", "test-api-key") + monkeypatch.delenv("POLICYAI_TAG_NAME", raising=False) + + with aioresponses() as m: + # Should use default "prod" tag + m.post( + "https://api.musubilabs.ai/policyai/v1/decisions/evaluate/prod", + payload={ + "data": [ + { + "status": "success", + "assessment": "SAFE", + "category": "Safe", + "severity": 0, + "reason": "Content is safe", + } + ] + }, + ) + + result = await call_policyai_api(text="Hello!") + assert result["assessment"] == "SAFE"