feat(policy): test playground for AI policy suggestions (#21608)

* fix aviation safety topic filter: remove overly broad exceptions, add cockpit access block words

* fix airline brand protection filter: add identifier words, competitor/ops block words, tighten exceptions

* feat(policy): add POST /policy/templates/test endpoint for testing guardrails before creating them

* feat(ui): add testPolicyTemplate networking function

* feat(ui): add test playground to AI policy suggestion modal

* test(policy): add tests for POST /policy/templates/test endpoint
This commit is contained in:
Ishaan Jaff
2026-02-19 14:09:20 -08:00
committed by GitHub
parent 779c355f1e
commit c9cdce96fa
4 changed files with 881 additions and 138 deletions
@@ -18,13 +18,13 @@ from typing import (
List,
Literal,
Optional,
TypedDict,
cast,
)
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from typing_extensions import TypedDict
from litellm._logging import verbose_proxy_logger
from litellm.constants import (
@@ -993,3 +993,150 @@ async def suggest_policy_templates(
description=data.description,
model=data.model,
)
class GuardrailTestResultEntry(TypedDict):
guardrail_name: str
action: str # "passed" | "blocked" | "masked" | "unsupported"
output_text: str
details: str
class TestPolicyTemplateRequest(BaseModel):
guardrail_definitions: List[dict] = Field(
description="All guardrailDefinitions from the policy template"
)
text: str = Field(description="Test input text to run guardrails against")
class TestPolicyTemplateResponse(TypedDict):
overall_action: str # worst-case across all guardrails
results: List[GuardrailTestResultEntry]
@router.post(
"/policy/templates/test",
tags=["policy management"],
dependencies=[Depends(user_api_key_auth)],
)
@management_endpoint_wrapper
async def test_policy_template(
data: TestPolicyTemplateRequest,
request: Request,
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
) -> TestPolicyTemplateResponse:
"""
Test a policy template's guardrails against a text input without creating them.
Instantiates temporary guardrails from the template definitions, runs them
against the provided text, and returns per-guardrail results so users can
verify the template solves their problem before creating it.
"""
from litellm.proxy.utils import handle_exception_on_proxy
try:
results = await _test_guardrail_definitions(
guardrail_definitions=data.guardrail_definitions,
text=data.text,
)
overall = _compute_overall_action(results)
return TestPolicyTemplateResponse(
overall_action=overall,
results=results,
)
except Exception as e:
raise handle_exception_on_proxy(e)
async def _test_guardrail_definitions(
guardrail_definitions: List[dict],
text: str,
) -> List[GuardrailTestResultEntry]:
"""Instantiate and run each guardrail definition against the text."""
from litellm.proxy.guardrails.guardrail_hooks.litellm_content_filter.content_filter import (
ContentFilterGuardrail,
)
results: List[GuardrailTestResultEntry] = []
for guardrail_def in guardrail_definitions:
guardrail_name = guardrail_def.get("guardrail_name", "unknown")
litellm_params = guardrail_def.get("litellm_params", {})
guardrail_type = litellm_params.get("guardrail", "")
if guardrail_type != "litellm_content_filter":
results.append(
GuardrailTestResultEntry(
guardrail_name=guardrail_name,
action="unsupported",
output_text=text,
details=f"Preview not available for guardrail type: {guardrail_type}",
)
)
continue
try:
guardrail = ContentFilterGuardrail(
guardrail_name=guardrail_name,
patterns=litellm_params.get("patterns"),
blocked_words=litellm_params.get("blocked_words"),
categories=litellm_params.get("categories"),
pattern_redaction_format=litellm_params.get("pattern_redaction_format"),
default_on=litellm_params.get("default_on", False),
)
output = await guardrail.apply_guardrail(
inputs={"texts": [text]},
request_data={},
input_type="request",
)
output_text = output.get("texts", [text])[0] if output.get("texts") else text
if output_text != text:
action = "masked"
details = "Content was modified (masked)"
else:
action = "passed"
details = "No issues detected"
results.append(
GuardrailTestResultEntry(
guardrail_name=guardrail_name,
action=action,
output_text=output_text,
details=details,
)
)
except HTTPException as e:
detail = e.detail if hasattr(e, "detail") else str(e)
if isinstance(detail, dict):
detail = detail.get("error", str(detail))
results.append(
GuardrailTestResultEntry(
guardrail_name=guardrail_name,
action="blocked",
output_text="",
details=str(detail),
)
)
except Exception as e:
results.append(
GuardrailTestResultEntry(
guardrail_name=guardrail_name,
action="error",
output_text=text,
details=str(e),
)
)
return results
def _compute_overall_action(results: List[GuardrailTestResultEntry]) -> str:
"""Return the worst-case action: blocked > masked > error > unsupported > passed."""
priority = {"blocked": 4, "masked": 3, "error": 2, "unsupported": 1, "passed": 0}
worst = "passed"
for r in results:
if priority.get(r["action"], 0) > priority.get(worst, 0):
worst = r["action"]
return worst
@@ -0,0 +1,213 @@
"""
Tests for POST /policy/templates/test endpoint logic.
Tests _test_guardrail_definitions and _compute_overall_action directly
without needing a running proxy.
"""
import pytest
from litellm.proxy.management_endpoints.policy_endpoints.endpoints import (
GuardrailTestResultEntry,
_compute_overall_action,
_test_guardrail_definitions,
)
@pytest.mark.asyncio
async def test_pattern_based_guardrail_masks_pii():
"""A pattern-based guardrail should mask matching PII."""
guardrail_defs = [
{
"guardrail_name": "test-ssn-masker",
"litellm_params": {
"guardrail": "litellm_content_filter",
"mode": "pre_call",
"patterns": [
{
"pattern_type": "prebuilt",
"pattern_name": "us_ssn",
"action": "MASK",
}
],
"pattern_redaction_format": "[{pattern_name}_REDACTED]",
},
"guardrail_info": {"description": "Masks US SSNs"},
}
]
results = await _test_guardrail_definitions(
guardrail_definitions=guardrail_defs,
text="My SSN is 123-45-6789",
)
assert len(results) == 1
assert results[0]["guardrail_name"] == "test-ssn-masker"
assert results[0]["action"] == "masked"
assert "123-45-6789" not in results[0]["output_text"]
assert "REDACTED" in results[0]["output_text"]
@pytest.mark.asyncio
async def test_blocked_words_guardrail_blocks():
"""A blocked_words guardrail should block matching text."""
guardrail_defs = [
{
"guardrail_name": "test-word-blocker",
"litellm_params": {
"guardrail": "litellm_content_filter",
"mode": "pre_call",
"blocked_words": [
{
"keyword": "forbidden_word",
"action": "BLOCK",
"description": "test block",
}
],
},
"guardrail_info": {"description": "Blocks forbidden words"},
}
]
results = await _test_guardrail_definitions(
guardrail_definitions=guardrail_defs,
text="This contains forbidden_word in it",
)
assert len(results) == 1
assert results[0]["guardrail_name"] == "test-word-blocker"
assert results[0]["action"] == "blocked"
@pytest.mark.asyncio
async def test_clean_text_passes():
"""Clean text should pass all guardrails."""
guardrail_defs = [
{
"guardrail_name": "test-ssn-masker",
"litellm_params": {
"guardrail": "litellm_content_filter",
"mode": "pre_call",
"patterns": [
{
"pattern_type": "prebuilt",
"pattern_name": "us_ssn",
"action": "MASK",
}
],
},
"guardrail_info": {"description": "Masks US SSNs"},
}
]
results = await _test_guardrail_definitions(
guardrail_definitions=guardrail_defs,
text="Hello, this is a perfectly clean message.",
)
assert len(results) == 1
assert results[0]["action"] == "passed"
assert results[0]["output_text"] == "Hello, this is a perfectly clean message."
@pytest.mark.asyncio
async def test_unsupported_guardrail_type():
"""Non-litellm_content_filter types should return unsupported."""
guardrail_defs = [
{
"guardrail_name": "test-mcp",
"litellm_params": {
"guardrail": "mcp_security",
"mode": "pre_call",
},
"guardrail_info": {"description": "MCP guardrail"},
}
]
results = await _test_guardrail_definitions(
guardrail_definitions=guardrail_defs,
text="Any text",
)
assert len(results) == 1
assert results[0]["action"] == "unsupported"
assert "mcp_security" in results[0]["details"]
@pytest.mark.asyncio
async def test_multiple_guardrails_mixed_results():
"""Multiple guardrails with different outcomes."""
guardrail_defs = [
{
"guardrail_name": "ssn-masker",
"litellm_params": {
"guardrail": "litellm_content_filter",
"mode": "pre_call",
"patterns": [
{
"pattern_type": "prebuilt",
"pattern_name": "us_ssn",
"action": "MASK",
}
],
"pattern_redaction_format": "[{pattern_name}_REDACTED]",
},
"guardrail_info": {"description": "Masks SSNs"},
},
{
"guardrail_name": "email-masker",
"litellm_params": {
"guardrail": "litellm_content_filter",
"mode": "pre_call",
"patterns": [
{
"pattern_type": "prebuilt",
"pattern_name": "email",
"action": "MASK",
}
],
"pattern_redaction_format": "[{pattern_name}_REDACTED]",
},
"guardrail_info": {"description": "Masks emails"},
},
]
results = await _test_guardrail_definitions(
guardrail_definitions=guardrail_defs,
text="My SSN is 123-45-6789 but no email here",
)
assert len(results) == 2
ssn_result = next(r for r in results if r["guardrail_name"] == "ssn-masker")
email_result = next(r for r in results if r["guardrail_name"] == "email-masker")
assert ssn_result["action"] == "masked"
assert email_result["action"] == "passed"
def test_compute_overall_action_blocked_wins():
results: list[GuardrailTestResultEntry] = [
GuardrailTestResultEntry(guardrail_name="a", action="passed", output_text="", details=""),
GuardrailTestResultEntry(guardrail_name="b", action="blocked", output_text="", details=""),
GuardrailTestResultEntry(guardrail_name="c", action="masked", output_text="", details=""),
]
assert _compute_overall_action(results) == "blocked"
def test_compute_overall_action_masked_wins_over_passed():
results: list[GuardrailTestResultEntry] = [
GuardrailTestResultEntry(guardrail_name="a", action="passed", output_text="", details=""),
GuardrailTestResultEntry(guardrail_name="b", action="masked", output_text="", details=""),
]
assert _compute_overall_action(results) == "masked"
def test_compute_overall_action_all_passed():
results: list[GuardrailTestResultEntry] = [
GuardrailTestResultEntry(guardrail_name="a", action="passed", output_text="", details=""),
GuardrailTestResultEntry(guardrail_name="b", action="passed", output_text="", details=""),
]
assert _compute_overall_action(results) == "passed"
def test_compute_overall_action_empty():
assert _compute_overall_action([]) == "passed"
@@ -5627,6 +5627,41 @@ export const suggestPolicyTemplates = async (
}
};
export const testPolicyTemplate = async (
accessToken: string,
guardrailDefinitions: any[],
text: string
) => {
try {
const url = proxyBaseUrl
? `${proxyBaseUrl}/policy/templates/test`
: `/policy/templates/test`;
const response = await fetch(url, {
method: "POST",
headers: {
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
guardrail_definitions: guardrailDefinitions,
text,
}),
});
if (!response.ok) {
const errorData = await response.json();
const errorMessage = deriveErrorMessage(errorData);
handleError(errorMessage);
throw new Error(errorMessage);
}
return response.json();
} catch (error) {
console.error("Failed to test policy template:", error);
throw error;
}
};
export const enrichPolicyTemplateStream = async (
accessToken: string,
templateId: string,
@@ -1,13 +1,24 @@
import React, { useState, useEffect } from "react";
import { Modal, Spin, Checkbox, Select } from "antd";
import { Button } from "@tremor/react";
import { suggestPolicyTemplates, modelHubCall } from "../networking";
import { Modal, Spin, Checkbox, Select, Input, Typography, Tooltip } from "antd";
import { Button, Card } from "@tremor/react";
import { CheckCircleOutlined, CloseCircleOutlined, InfoCircleOutlined, DownOutlined, RightOutlined } from "@ant-design/icons";
import { suggestPolicyTemplates, modelHubCall, testPolicyTemplate, enrichPolicyTemplate } from "../networking";
const { TextArea } = Input;
const { Text } = Typography;
interface SuggestedTemplate {
template_id: string;
reason: string;
}
interface GuardrailTestResult {
guardrail_name: string;
action: string;
output_text: string;
details: string;
}
interface AiSuggestionModalProps {
visible: boolean;
onSelectTemplates: (templates: any[]) => void;
@@ -34,6 +45,17 @@ const AiSuggestionModal: React.FC<AiSuggestionModalProps> = ({
const [selectedModel, setSelectedModel] = useState<string | undefined>(undefined);
const [availableModels, setAvailableModels] = useState<string[]>([]);
const [isLoadingModels, setIsLoadingModels] = useState(false);
// Test panel state
const [showTestPanel, setShowTestPanel] = useState(false);
const [testInputText, setTestInputText] = useState("");
const [isTestLoading, setIsTestLoading] = useState(false);
const [testResults, setTestResults] = useState<GuardrailTestResult[] | null>(null);
const [testOverallAction, setTestOverallAction] = useState<string | null>(null);
const [collapsedResults, setCollapsedResults] = useState<Set<string>>(new Set());
// Enrichment state for competitor templates
const [enrichedDefs, setEnrichedDefs] = useState<Record<string, any[]>>({});
const [isEnriching, setIsEnriching] = useState(false);
const [enrichBrandName, setEnrichBrandName] = useState("");
useEffect(() => {
if (visible && availableModels.length === 0) {
@@ -67,6 +89,15 @@ const AiSuggestionModal: React.FC<AiSuggestionModalProps> = ({
setExplanation(null);
setSelectedIds(new Set());
setSelectedModel(undefined);
setShowTestPanel(false);
setTestInputText("");
setIsTestLoading(false);
setTestResults(null);
setTestOverallAction(null);
setCollapsedResults(new Set());
setEnrichedDefs({});
setIsEnriching(false);
setEnrichBrandName("");
};
const handleCancel = () => {
@@ -126,6 +157,11 @@ const AiSuggestionModal: React.FC<AiSuggestionModalProps> = ({
setSuggestions(null);
setExplanation(null);
setSelectedIds(new Set());
setShowTestPanel(false);
setTestInputText("");
setTestResults(null);
setTestOverallAction(null);
setCollapsedResults(new Set());
};
const handleUseSelected = () => {
@@ -149,14 +185,433 @@ const AiSuggestionModal: React.FC<AiSuggestionModalProps> = ({
const getTemplateById = (id: string) =>
allTemplates.find((t) => t.id === id);
const toggleResultCollapse = (name: string) => {
setCollapsedResults((prev) => {
const next = new Set(prev);
if (next.has(name)) next.delete(name);
else next.add(name);
return next;
});
};
const getSelectedTemplatesNeedingEnrichment = (): any[] => {
return Array.from(selectedIds)
.map((id) => getTemplateById(id))
.filter((t) => t?.llm_enrichment);
};
const needsEnrichment = getSelectedTemplatesNeedingEnrichment().length > 0;
const getAllSelectedGuardrailDefs = (): any[] => {
const defs: any[] = [];
for (const id of selectedIds) {
// Use enriched defs if available, otherwise use template's original
if (enrichedDefs[id]) {
defs.push(...enrichedDefs[id]);
} else {
const t = getTemplateById(id);
if (t?.guardrailDefinitions) {
defs.push(...t.guardrailDefinitions);
}
}
}
return defs;
};
const handleEnrichCompetitors = async () => {
if (!accessToken || !selectedModel) return;
const templatesToEnrich = getSelectedTemplatesNeedingEnrichment();
if (templatesToEnrich.length === 0) return;
setIsEnriching(true);
try {
for (const template of templatesToEnrich) {
const paramName = template.llm_enrichment.parameter;
const result = await enrichPolicyTemplate(
accessToken,
template.id,
{ [paramName]: enrichBrandName },
selectedModel,
);
setEnrichedDefs((prev) => ({
...prev,
[template.id]: result.guardrailDefinitions,
}));
}
} catch (e) {
console.error("Failed to enrich templates:", e);
} finally {
setIsEnriching(false);
}
};
const handleRunTest = async () => {
if (!accessToken || !testInputText.trim()) return;
const allDefs = getAllSelectedGuardrailDefs();
if (allDefs.length === 0) return;
setIsTestLoading(true);
setTestResults(null);
setTestOverallAction(null);
setCollapsedResults(new Set());
try {
const result = await testPolicyTemplate(accessToken, allDefs, testInputText);
setTestResults(result.results || []);
setTestOverallAction(result.overall_action || "passed");
} catch {
setTestResults([]);
setTestOverallAction("error");
} finally {
setIsTestLoading(false);
}
};
const handleTestKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey && !e.ctrlKey && !e.metaKey) {
e.preventDefault();
handleRunTest();
}
};
const showResults = suggestions !== null && !isLoading;
// Helper to render the suggestions list (reused in both layouts)
const renderSuggestionsList = () => {
if (!suggestions || suggestions.length === 0) {
return (
<div className="text-center py-12 text-gray-500">
<svg className="w-12 h-12 mx-auto mb-3 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p className="font-medium">No matching templates found</p>
<p className="text-sm mt-1">Try adjusting your examples or description.</p>
</div>
);
}
return (
<div className="space-y-3">
{suggestions.map((suggestion) => {
const template = getTemplateById(suggestion.template_id);
if (!template) return null;
const isSelected = selectedIds.has(suggestion.template_id);
return (
<div
key={suggestion.template_id}
className={`rounded-xl border-2 transition-all ${
isSelected
? "border-blue-400 bg-blue-50/60 shadow-sm"
: "border-gray-200 hover:border-gray-300 hover:shadow-sm"
}`}
>
<div
className="p-4 cursor-pointer"
onClick={() => toggleTemplate(suggestion.template_id)}
>
<div className="flex items-start gap-3">
<Checkbox
checked={isSelected}
onChange={() => toggleTemplate(suggestion.template_id)}
className="mt-0.5"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-semibold text-sm text-gray-900">
{template.title}
</span>
{template.complexity && (
<span className={`px-2 py-0.5 rounded-full text-[10px] font-medium border ${
template.complexity === "Low"
? "bg-gray-50 text-gray-500 border-gray-200"
: template.complexity === "Medium"
? "bg-blue-50 text-blue-500 border-blue-100"
: "bg-purple-50 text-purple-500 border-purple-100"
}`}>
{template.complexity}
</span>
)}
</div>
<p className="text-xs text-gray-500 leading-relaxed">
{template.description}
</p>
<div className="flex flex-wrap items-center gap-1.5 mt-2">
{template.guardrails && template.guardrails.slice(0, 4).map((g: string) => (
<span key={g} className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-gray-100 text-gray-600">
{g}
</span>
))}
{template.guardrails && template.guardrails.length > 4 && (
<span className="text-[10px] text-gray-400">
+{template.guardrails.length - 4} more
</span>
)}
</div>
<div className="mt-2 flex items-start gap-1.5">
<InfoCircleOutlined className="text-blue-500 mt-0.5 text-xs flex-shrink-0" />
<p className="text-xs text-blue-600 leading-relaxed">
{suggestion.reason}
</p>
</div>
</div>
</div>
</div>
</div>
);
})}
{/* Explanation */}
{explanation && (
<div className="p-3 bg-gray-50 rounded-xl border border-gray-200">
<div className="flex items-center gap-2 mb-1">
<InfoCircleOutlined className="text-gray-400 text-xs" />
<span className="text-[10px] font-semibold text-gray-500 uppercase tracking-wider">
Why these templates
</span>
</div>
<p className="text-xs text-gray-600 leading-relaxed">{explanation}</p>
</div>
)}
</div>
);
};
// Helper to render the test panel
const renderTestPanel = () => (
<div className="space-y-4 h-full flex flex-col">
{/* Test header */}
<div className="pb-3 border-b border-gray-200">
<div className="flex items-center justify-between mb-1">
<h3 className="text-base font-semibold text-gray-900">Test Guardrails</h3>
<button
onClick={() => { setShowTestPanel(false); setTestResults(null); setTestOverallAction(null); }}
className="text-gray-400 hover:text-gray-600"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="flex flex-wrap gap-1.5 mb-1.5">
{Array.from(selectedIds).map((id) => {
const t = getTemplateById(id);
return t ? (
<span key={id} className="inline-flex items-center px-2 py-0.5 rounded-md text-[10px] font-medium bg-blue-50 text-blue-700 border border-blue-200">
{t.title}
</span>
) : null;
})}
</div>
<p className="text-xs text-gray-500">
{getAllSelectedGuardrailDefs().length} guardrails across {selectedIds.size} template{selectedIds.size !== 1 ? "s" : ""}
</p>
</div>
{/* Enrichment section for competitor templates */}
{needsEnrichment && Object.keys(enrichedDefs).length > 0 && (
<div className="p-3 bg-green-50 rounded-lg border border-green-200">
<div className="flex items-center gap-2">
<CheckCircleOutlined className="text-green-600" />
<span className="text-xs font-medium text-green-800">
Competitor names loaded for {enrichBrandName}
</span>
</div>
</div>
)}
{needsEnrichment && Object.keys(enrichedDefs).length === 0 && (
<div className="p-3 bg-amber-50 rounded-lg border border-amber-200 space-y-2">
<div className="flex items-center gap-2">
<svg className="w-4 h-4 text-amber-600 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
<span className="text-xs font-medium text-amber-800">
Competitor template requires your brand name to discover competitors
</span>
</div>
<div className="flex gap-2">
<Input
size="small"
placeholder="e.g. Emirates Airlines"
value={enrichBrandName}
onChange={(e) => setEnrichBrandName(e.target.value)}
onPressEnter={() => enrichBrandName.trim() && handleEnrichCompetitors()}
className="flex-1"
/>
<Button
size="xs"
onClick={handleEnrichCompetitors}
loading={isEnriching}
disabled={!enrichBrandName.trim() || isEnriching}
>
{isEnriching ? "Discovering..." : "Discover"}
</Button>
</div>
</div>
)}
{/* Input */}
<div className="space-y-3">
<div>
<div className="flex justify-between items-center mb-2">
<div className="flex items-center gap-2">
<label className="text-sm font-medium text-gray-700">Input Text</label>
<Tooltip title="Press Enter to submit. Use Shift+Enter for new line.">
<InfoCircleOutlined className="text-gray-400 cursor-help" />
</Tooltip>
</div>
<Text className="text-xs text-gray-500">Characters: {testInputText.length}</Text>
</div>
<TextArea
value={testInputText}
onChange={(e) => setTestInputText(e.target.value)}
onKeyDown={handleTestKeyDown}
placeholder="Enter text to test against all selected policy guardrails..."
rows={4}
className="font-mono text-sm"
/>
<div className="mt-1">
<Text className="text-xs text-gray-500">
Press <kbd className="px-1 py-0.5 bg-gray-100 border border-gray-300 rounded text-xs">Enter</kbd> to submit
</Text>
</div>
</div>
<Button
onClick={handleRunTest}
loading={isTestLoading}
disabled={!testInputText.trim() || isTestLoading}
className="w-full"
>
{isTestLoading
? `Testing ${getAllSelectedGuardrailDefs().length} guardrails...`
: `Test ${getAllSelectedGuardrailDefs().length} guardrails`}
</Button>
</div>
{/* Results */}
{testResults && testResults.length > 0 && (() => {
const blockedCount = testResults.filter((r) => r.action === "blocked").length;
const maskedCount = testResults.filter((r) => r.action === "masked").length;
const passedCount = testResults.filter((r) => r.action === "passed").length;
const otherCount = testResults.length - blockedCount - maskedCount - passedCount;
return (
<div className="space-y-2 pt-3 border-t border-gray-200 flex-1 overflow-y-auto">
{/* Summary bar */}
<div className="rounded-lg border border-gray-200 bg-gray-50 p-3 mb-3">
<div className="flex items-center justify-between mb-2">
<h4 className="text-sm font-semibold text-gray-900">Results</h4>
<span className="text-[10px] text-gray-500">{testResults.length} guardrails tested</span>
</div>
<div className="flex gap-2">
{blockedCount > 0 && (
<div className="flex-1 rounded-md bg-red-50 border border-red-200 px-3 py-2 text-center">
<div className="text-lg font-bold text-red-700">{blockedCount}</div>
<div className="text-[10px] font-medium text-red-600">Blocked</div>
</div>
)}
{maskedCount > 0 && (
<div className="flex-1 rounded-md bg-amber-50 border border-amber-200 px-3 py-2 text-center">
<div className="text-lg font-bold text-amber-700">{maskedCount}</div>
<div className="text-[10px] font-medium text-amber-600">Masked</div>
</div>
)}
<div className="flex-1 rounded-md bg-green-50 border border-green-200 px-3 py-2 text-center">
<div className="text-lg font-bold text-green-700">{passedCount}</div>
<div className="text-[10px] font-medium text-green-600">Passed</div>
</div>
{otherCount > 0 && (
<div className="flex-1 rounded-md bg-gray-100 border border-gray-200 px-3 py-2 text-center">
<div className="text-lg font-bold text-gray-600">{otherCount}</div>
<div className="text-[10px] font-medium text-gray-500">Other</div>
</div>
)}
</div>
</div>
{testResults.map((result) => {
const isBlocked = result.action === "blocked";
const isMasked = result.action === "masked";
const isPassed = result.action === "passed";
const isCollapsed = collapsedResults.has(result.guardrail_name);
return (
<Card
key={result.guardrail_name}
className={`!p-3 ${
isBlocked
? "bg-red-50 border-red-200"
: isMasked
? "bg-amber-50 border-amber-200"
: isPassed
? "bg-green-50 border-green-200"
: "bg-gray-50 border-gray-200"
}`}
>
<div className="space-y-2">
<div
className="flex items-center justify-between cursor-pointer"
onClick={() => toggleResultCollapse(result.guardrail_name)}
>
<div className="flex items-center space-x-1.5">
{isCollapsed ? <RightOutlined className="text-gray-500 text-[10px]" /> : <DownOutlined className="text-gray-500 text-[10px]" />}
{isBlocked ? (
<CloseCircleOutlined className="text-red-600" />
) : isMasked ? (
<svg className="w-4 h-4 text-amber-600" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
) : (
<CheckCircleOutlined className="text-green-600" />
)}
<span className={`text-xs font-medium ${isBlocked ? "text-red-800" : isMasked ? "text-amber-800" : "text-green-800"}`}>
{result.guardrail_name}
</span>
<span className={`px-1.5 py-0.5 rounded-full text-[10px] font-semibold ${
isBlocked ? "bg-red-100 text-red-700" : isMasked ? "bg-amber-100 text-amber-700" : isPassed ? "bg-green-100 text-green-700" : "bg-gray-100 text-gray-600"
}`}>
{result.action.charAt(0).toUpperCase() + result.action.slice(1)}
</span>
</div>
</div>
{!isCollapsed && (
<>
{isMasked && result.output_text && (
<div className="bg-white border border-amber-200 rounded p-2">
<label className="text-[10px] font-medium text-gray-600 mb-1 block">Output Text</label>
<div className="font-mono text-xs text-gray-900 whitespace-pre-wrap break-words">{result.output_text}</div>
</div>
)}
{isBlocked && result.details && (
<div className="bg-white border border-red-200 rounded p-2">
<label className="text-[10px] font-medium text-gray-600 mb-1 block">Details</label>
<p className="text-xs text-red-700">{result.details}</p>
</div>
)}
{isPassed && (
<div className="text-[10px] text-green-700">Passed unchanged.</div>
)}
</>
)}
</div>
</Card>
);
})}
</div>
);
})()}
{testResults && testResults.length === 0 && !isTestLoading && (
<p className="text-xs text-gray-400 text-center py-3">No testable guardrails in selected templates.</p>
)}
</div>
);
return (
<Modal
title={null}
open={visible}
onCancel={handleCancel}
width={820}
width={showTestPanel ? 1200 : 820}
footer={null}
styles={{ body: { padding: 0 } }}
>
@@ -290,26 +745,14 @@ const AiSuggestionModal: React.FC<AiSuggestionModalProps> = ({
{isLoading && (
<div className="flex items-center justify-center gap-3 p-4 bg-gray-50 rounded-lg border border-gray-200">
<Spin size="small" />
<span className="text-sm text-gray-600">
Analyzing your requirements...
</span>
<span className="text-sm text-gray-600">Analyzing your requirements...</span>
</div>
)}
{/* Footer */}
<div className="flex justify-end gap-3 pt-2">
<Button
variant="secondary"
onClick={handleCancel}
disabled={isLoading}
>
Cancel
</Button>
<Button
onClick={handleSuggest}
loading={isLoading}
disabled={!hasInput || !selectedModel || isLoading}
>
<Button variant="secondary" onClick={handleCancel} disabled={isLoading}>Cancel</Button>
<Button onClick={handleSuggest} loading={isLoading} disabled={!hasInput || !selectedModel || isLoading}>
{isLoading ? "Analyzing..." : "Suggest Policies"}
</Button>
</div>
@@ -317,130 +760,35 @@ const AiSuggestionModal: React.FC<AiSuggestionModalProps> = ({
) : (
/* ── Results phase ── */
<div className="px-8 py-6">
{suggestions && suggestions.length > 0 ? (
<div className="space-y-3 max-h-[450px] overflow-y-auto pr-1">
{suggestions.map((suggestion) => {
const template = getTemplateById(suggestion.template_id);
if (!template) return null;
const isSelected = selectedIds.has(suggestion.template_id);
return (
<div
key={suggestion.template_id}
className={`p-4 rounded-xl border-2 cursor-pointer transition-all ${
isSelected
? "border-blue-400 bg-blue-50/60 shadow-sm"
: "border-gray-200 hover:border-gray-300 hover:shadow-sm"
}`}
onClick={() => toggleTemplate(suggestion.template_id)}
>
<div className="flex items-start gap-3">
<Checkbox
checked={isSelected}
onChange={() => toggleTemplate(suggestion.template_id)}
className="mt-0.5"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-semibold text-sm text-gray-900">
{template.title}
</span>
{template.complexity && (
<span className={`px-2 py-0.5 rounded-full text-[10px] font-medium border ${
template.complexity === "Low"
? "bg-gray-50 text-gray-500 border-gray-200"
: template.complexity === "Medium"
? "bg-blue-50 text-blue-500 border-blue-100"
: "bg-purple-50 text-purple-500 border-purple-100"
}`}>
{template.complexity}
</span>
)}
</div>
<p className="text-xs text-gray-500 leading-relaxed">
{template.description}
</p>
<div className="flex flex-wrap items-center gap-1.5 mt-2">
{template.guardrails && template.guardrails.slice(0, 4).map((g: string) => (
<span key={g} className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-gray-100 text-gray-600">
{g}
</span>
))}
{template.guardrails && template.guardrails.length > 4 && (
<span className="text-[10px] text-gray-400">
+{template.guardrails.length - 4} more
</span>
)}
{template.estimated_latency && (
<>
<span className="text-gray-300">|</span>
<span className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-medium ${
template.estimated_latency.includes("<1ms")
? "bg-green-50 text-green-600"
: "bg-amber-50 text-amber-600"
}`}>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{template.estimated_latency}
</span>
</>
)}
</div>
<div className="mt-2.5 flex items-start gap-1.5">
<svg className="w-3.5 h-3.5 text-blue-500 mt-0.5 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
</svg>
<p className="text-xs text-blue-600 leading-relaxed">
{suggestion.reason}
</p>
</div>
</div>
</div>
</div>
);
})}
{showTestPanel && selectedIds.size > 0 ? (
/* Side-by-side layout: suggestions left, test panel right */
<div className="flex gap-6" style={{ minHeight: "500px", maxHeight: "70vh" }}>
{/* Left: suggestions */}
<div className="w-1/2 overflow-y-auto pr-2">
{renderSuggestionsList()}
</div>
{/* Right: test panel */}
<div className="w-1/2 border-l border-gray-200 pl-6 overflow-y-auto">
{renderTestPanel()}
</div>
</div>
) : (
<div className="text-center py-12 text-gray-500">
<svg className="w-12 h-12 mx-auto mb-3 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p className="font-medium">No matching templates found</p>
<p className="text-sm mt-1">
Try adjusting your examples or description.
</p>
</div>
)}
{/* Explanation */}
{explanation && suggestions && suggestions.length > 0 && (
<div className="mt-4 p-4 bg-gray-50 rounded-xl border border-gray-200">
<div className="flex items-center gap-2 mb-1.5">
<svg className="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span className="text-xs font-semibold text-gray-500 uppercase tracking-wider">
Why these templates
</span>
</div>
<p className="text-sm text-gray-600 leading-relaxed">{explanation}</p>
/* Normal single-column layout */
<div className="max-h-[520px] overflow-y-auto pr-1">
{renderSuggestionsList()}
</div>
)}
{/* Footer */}
<div className="flex justify-end gap-3 pt-6">
<Button
variant="secondary"
onClick={handleBack}
>
Back
</Button>
<Button
onClick={handleUseSelected}
disabled={selectedIds.size === 0}
>
Use {selectedIds.size} Selected Template
{selectedIds.size !== 1 ? "s" : ""}
<div className="flex justify-end gap-3 pt-6 border-t border-gray-100 mt-4">
<Button variant="secondary" onClick={handleBack}>Back</Button>
{suggestions && suggestions.length > 0 && selectedIds.size > 0 && !showTestPanel && (
<Button variant="secondary" onClick={() => setShowTestPanel(true)}>
Test Suggestions
</Button>
)}
<Button onClick={handleUseSelected} disabled={selectedIds.size === 0}>
Use {selectedIds.size} Selected Template{selectedIds.size !== 1 ? "s" : ""}
</Button>
</div>
</div>