Merge pull request #14696 from ARajan1084/bedrock-guardrail-view

NEW Amazon Bedrock Guardrail Info View in Logs
This commit is contained in:
Krish Dholakia 2025-09-18 17:22:17 -07:00 • committed by GitHub
commit c7af9d1b7e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 3492 additions and 265 deletions

View file

@ -2879,7 +2879,34 @@ jobs:
name: Install Playwright Browsers
command: |
npx playwright install
- run:
name: Run UI unit tests (Vitest)
command: |
# Use the same Node version we installed earlier with nvm
export NVM_DIR="/opt/circleci/.nvm"
source "$NVM_DIR/nvm.sh"
nvm use v18.17.0
cd ui/litellm-dashboard
# Ensure clean, deterministic install (skip if you prefer npm install)
npm ci || npm install
# Run Vitest via your package.json script:
# - --run to avoid watch mode on CI
# - --coverage to produce coverage
# - --coverage.reporter=lcov so Codecov can ingest lcov.info
# - Feel free to drop --reporter if you don’t need extra output
npm run test -- --run --coverage --coverage.reporter=lcov
# If you use a JUnit reporter (e.g., vitest-junit-reporter),
# ensure it writes to ./test-results/junit.xml so Circle collects it
mkdir -p test-results || true
- store_test_results:
path: ui/litellm-dashboard/test-results
- store_artifacts:
path: ui/litellm-dashboard/coverage
destination: ui-coverage
- run:
name: Build Docker image
command: docker build -t my-app:latest -f ./docker/Dockerfile.database .

View file

@ -357,6 +357,7 @@ class CustomGuardrail(CustomLogger):
end_time: Optional[float] = None,
duration: Optional[float] = None,
masked_entity_count: Optional[Dict[str, int]] = None,
guardrail_provider: Optional[str] = None,
) -> None:
"""
Builds `StandardLoggingGuardrailInformation` and adds it to the request metadata so it can be used for logging to DataDog, Langfuse, etc.
@ -367,6 +368,7 @@ class CustomGuardrail(CustomLogger):
slg = StandardLoggingGuardrailInformation(
guardrail_name=self.guardrail_name,
guardrail_provider=guardrail_provider,
guardrail_mode=(
GuardrailMode(**self.event_hook.model_dump()) # type: ignore
if isinstance(self.event_hook, Mode)

View file

@ -111,6 +111,7 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM):
)
self.guardrailIdentifier = guardrailIdentifier
self.guardrailVersion = guardrailVersion
self.guardrail_provider = "bedrock"
# store kwargs as optional_params
self.optional_params = kwargs
@ -372,6 +373,7 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM):
# Add guardrail information to request trace
#########################################################
self.add_standard_logging_guardrail_information_to_request_data(
guardrail_provider=self.guardrail_provider,
guardrail_json_response=response.json(),
request_data=request_data or {},
guardrail_status=self._get_bedrock_guardrail_response_status(

View file

@ -76,6 +76,7 @@ class _OPTIONAL_PresidioPIIMasking(CustomGuardrail):
self.logging_only = True
kwargs["event_hook"] = GuardrailEventHooks.logging_only
super().__init__(**kwargs)
self.guardrail_provider = "presidio"
self.pii_tokens: dict = (
{}
) # mapping of PII token to original text - only used with Presidio `replace` operation
@ -369,6 +370,7 @@ class _OPTIONAL_PresidioPIIMasking(CustomGuardrail):
else:
guardrail_json_response = exception_str
self.add_standard_logging_guardrail_information_to_request_data(
guardrail_provider=self.guardrail_provider,
guardrail_json_response=guardrail_json_response,
request_data=request_data,
guardrail_status=status,

View file

@ -2015,6 +2015,7 @@ class GuardrailMode(TypedDict, total=False):
class StandardLoggingGuardrailInformation(TypedDict, total=False):
guardrail_name: Optional[str]
guardrail_provider: Optional[str]
guardrail_mode: Optional[
Union[GuardrailEventHooks, List[GuardrailEventHooks], GuardrailMode]
]

File diff suppressed because it is too large Load diff

View file

@ -6,7 +6,9 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
"lint": "next lint",
"test": "vitest",
"test:watch": "vitest -w"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.54.0",
@ -40,6 +42,9 @@
},
"devDependencies": {
"@tailwindcss/forms": "^0.5.7",
"@testing-library/jest-dom": "^6.8.0",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/lodash": "^4.17.15",
"@types/node": "^20",
"@types/react": "18.2.48",
@ -50,10 +55,13 @@
"autoprefixer": "^10.4.17",
"eslint": "^8",
"eslint-config-next": "14.2.32",
"jsdom": "^27.0.0",
"postcss": "^8.4.33",
"prettier": "3.2.5",
"tailwindcss": "^3.4.1",
"typescript": "5.3.3"
"typescript": "5.3.3",
"vite": "^5.4.20",
"vitest": "^1.6.1"
},
"overrides": {
"prismjs": ">=1.30.0",

View file

@ -1,257 +0,0 @@
import React, { useState } from "react";
interface RecognitionMetadata {
recognizer_name: string;
recognizer_identifier: string;
}
interface GuardrailEntity {
end: number;
score: number;
start: number;
entity_type: string;
analysis_explanation: string | null;
recognition_metadata: RecognitionMetadata;
}
interface MaskedEntityCount {
[key: string]: number;
}
interface GuardrailInformation {
duration: number;
end_time: number;
start_time: number;
guardrail_mode: string;
guardrail_name: string;
guardrail_status: string;
guardrail_response: GuardrailEntity[];
masked_entity_count: MaskedEntityCount;
}
interface GuardrailViewerProps {
data: GuardrailInformation;
}
export function GuardrailViewer({ data }: GuardrailViewerProps) {
const [sectionExpanded, setSectionExpanded] = useState(true);
const [entityListExpanded, setEntityListExpanded] = useState(true);
const [expandedEntities, setExpandedEntities] = useState<Record<number, boolean>>({});
if (!data) {
return null;
}
// Calculate total masked entities
const totalMaskedEntities = data.masked_entity_count ?
Object.values(data.masked_entity_count).reduce((sum, count) => sum + count, 0) : 0;
const formatTime = (timestamp: number): string => {
const date = new Date(timestamp * 1000);
return date.toLocaleString();
};
const toggleEntity = (index: number) => {
setExpandedEntities(prev => ({
...prev,
[index]: !prev[index]
}));
};
const getScoreColor = (score: number): string => {
if (score >= 0.8) return "text-green-600";
return "text-yellow-600";
};
return (
<div className="bg-white rounded-lg shadow mb-6">
<div
className="flex justify-between items-center p-4 border-b cursor-pointer hover:bg-gray-50"
onClick={() => setSectionExpanded(!sectionExpanded)}
>
<div className="flex items-center">
<svg
className={`w-5 h-5 mr-2 text-gray-600 transition-transform ${sectionExpanded ? 'transform rotate-90' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
<h3 className="text-lg font-medium">Guardrail Information</h3>
<span className={`ml-3 px-2 py-1 rounded-md text-xs font-medium inline-block ${
data.guardrail_status === "success"
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}`}>
{data.guardrail_status}
</span>
{totalMaskedEntities > 0 && (
<span className="ml-3 px-2 py-1 bg-blue-50 text-blue-700 rounded-md text-xs font-medium">
{totalMaskedEntities} masked {totalMaskedEntities === 1 ? 'entity' : 'entities'}
</span>
)}
</div>
<span className="text-sm text-gray-500">{sectionExpanded ? 'Click to collapse' : 'Click to expand'}</span>
</div>
{sectionExpanded && (
<div className="p-4">
<div className="bg-white rounded-lg border p-4 mb-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<div className="flex">
<span className="font-medium w-1/3">Guardrail Name:</span>
<span className="font-mono">{data.guardrail_name}</span>
</div>
<div className="flex">
<span className="font-medium w-1/3">Mode:</span>
<span className="font-mono">{data.guardrail_mode}</span>
</div>
<div className="flex">
<span className="font-medium w-1/3">Status:</span>
<span className={`px-2 py-1 rounded-md text-xs font-medium inline-block ${
data.guardrail_status === "success"
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}`}>
{data.guardrail_status}
</span>
</div>
</div>
<div className="space-y-2">
<div className="flex">
<span className="font-medium w-1/3">Start Time:</span>
<span>{formatTime(data.start_time)}</span>
</div>
<div className="flex">
<span className="font-medium w-1/3">End Time:</span>
<span>{formatTime(data.end_time)}</span>
</div>
<div className="flex">
<span className="font-medium w-1/3">Duration:</span>
<span>{data.duration.toFixed(4)}s</span>
</div>
</div>
</div>
{/* Masked Entity Summary */}
{data.masked_entity_count && Object.keys(data.masked_entity_count).length > 0 && (
<div className="mt-4 pt-4 border-t">
<h4 className="font-medium mb-2">Masked Entity Summary</h4>
<div className="flex flex-wrap gap-2">
{Object.entries(data.masked_entity_count).map(([entityType, count]) => (
<span key={entityType} className="px-3 py-1.5 bg-blue-50 text-blue-700 rounded-md text-xs font-medium">
{entityType}: {count}
</span>
))}
</div>
</div>
)}
</div>
{/* Detected Entities Section */}
{data.guardrail_response && data.guardrail_response.length > 0 && (
<div className="mt-4">
<div
className="flex items-center mb-2 cursor-pointer"
onClick={() => setEntityListExpanded(!entityListExpanded)}
>
<svg
className={`w-5 h-5 mr-2 transition-transform ${entityListExpanded ? 'transform rotate-90' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
<h4 className="font-medium">Detected Entities ({data.guardrail_response.length})</h4>
</div>
{entityListExpanded && (
<div className="space-y-2">
{data.guardrail_response.map((entity, index) => {
const isExpanded = expandedEntities[index] || false;
return (
<div key={index} className="border rounded-lg overflow-hidden">
<div
className="flex items-center justify-between p-3 bg-gray-50 cursor-pointer hover:bg-gray-100"
onClick={() => toggleEntity(index)}
>
<div className="flex items-center">
<svg
className={`w-5 h-5 mr-2 transition-transform ${isExpanded ? 'transform rotate-90' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
<span className="font-medium mr-2">{entity.entity_type}</span>
<span className={`font-mono ${getScoreColor(entity.score)}`}>
Score: {entity.score.toFixed(2)}
</span>
</div>
<span className="text-xs text-gray-500">
Position: {entity.start}-{entity.end}
</span>
</div>
{isExpanded && (
<div className="p-3 border-t bg-white">
<div className="grid grid-cols-2 gap-4 mb-2">
<div className="space-y-2">
<div className="flex">
<span className="font-medium w-1/3">Entity Type:</span>
<span>{entity.entity_type}</span>
</div>
<div className="flex">
<span className="font-medium w-1/3">Position:</span>
<span>Characters {entity.start}-{entity.end}</span>
</div>
<div className="flex">
<span className="font-medium w-1/3">Confidence:</span>
<span className={getScoreColor(entity.score)}>
{entity.score.toFixed(2)}
</span>
</div>
</div>
<div className="space-y-2">
{entity.recognition_metadata && (
<>
<div className="flex">
<span className="font-medium w-1/3">Recognizer:</span>
<span>{entity.recognition_metadata.recognizer_name}</span>
</div>
<div className="flex overflow-hidden">
<span className="font-medium w-1/3">Identifier:</span>
<span className="truncate text-xs font-mono">
{entity.recognition_metadata.recognizer_identifier}
</span>
</div>
</>
)}
{entity.analysis_explanation && (
<div className="flex">
<span className="font-medium w-1/3">Explanation:</span>
<span>{entity.analysis_explanation}</span>
</div>
)}
</div>
</div>
</div>
)}
</div>
);
})}
</div>
)}
</div>
)}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,118 @@
import React from 'react';
import { describe, it, expect } from 'vitest';
import BedrockGuardrailDetails, {
BedrockGuardrailResponse,
} from '@/components/view_logs/GuardrailViewer/BedrockGuardrailDetails';
import { renderWithProviders, screen } from "../../../../tests/test-utils"
import {
makeAssessment,
makeBedrockCoverage,
makeBedrockResponse,
makeBedrockUsage,
} from "@/components/view_logs/GuardrailViewer/__tests__/fixtures"
describe('BedrockGuardrailDetails', () => {
it('returns null when response is falsy', () => {
// @ts-expect-error testing nullish handling
const { container } = renderWithProviders(<BedrockGuardrailDetails response={undefined} />);
expect(container).toBeEmptyDOMElement();
});
it('renders top summary: action chip, reason, blocked response', () => {
const resp: BedrockGuardrailResponse = makeBedrockResponse({
action: 'GUARDRAIL_INTERVENED',
actionReason: 'Policy violation',
blockedResponse: '[blocked]',
});
renderWithProviders(<BedrockGuardrailDetails response={resp} />);
expect(screen.getByText('Action:')).toBeInTheDocument();
expect(screen.getByText('Policy violation')).toBeInTheDocument();
expect(screen.getByText('[blocked]')).toBeInTheDocument();
});
it('renders coverage and usage pills', () => {
const resp = makeBedrockResponse({
guardrailCoverage: makeBedrockCoverage(),
usage: makeBedrockUsage({ contentPolicyUnits: 7, wordPolicyUnits: 1 }),
});
renderWithProviders(<BedrockGuardrailDetails response={resp} />);
expect(screen.getByText(/text guarded 27\/100/)).toBeInTheDocument();
expect(screen.getByText(/images guarded 1\/3/)).toBeInTheDocument();
expect(screen.getByText(/contentPolicyUnits: 7/)).toBeInTheDocument();
expect(screen.getByText(/wordPolicyUnits: 1/)).toBeInTheDocument();
});
it('renders outputs when present (prefers `outputs`, falls back to `output`)', () => {
// Using outputs
let resp = makeBedrockResponse({ outputs: [{ text: 'hello' }] });
const { rerender } = renderWithProviders(<BedrockGuardrailDetails response={resp} />);
expect(screen.getByText('Outputs')).toBeInTheDocument();
expect(screen.getByText('hello')).toBeInTheDocument();
// Using output
resp = makeBedrockResponse({ outputs: undefined, output: [{ text: 'world' }] });
rerender(<BedrockGuardrailDetails response={resp} />);
expect(screen.getByText('world')).toBeInTheDocument();
});
it('renders assessments with all policy sections and metrics', () => {
const resp = makeBedrockResponse({
assessments: [makeAssessment()],
});
renderWithProviders(<BedrockGuardrailDetails response={resp} />);
// Assessment section present
expect(screen.getByText('Assessment #1')).toBeInTheDocument();
// Word policy sections
expect(screen.getByText('Word Policy')).toBeInTheDocument();
expect(screen.getByText('Custom Words')).toBeInTheDocument();
expect(screen.getByText('Managed Word Lists')).toBeInTheDocument();
// Contextual grounding table headers
expect(screen.getByText('Contextual Grounding')).toBeInTheDocument();
expect(screen.getAllByText('Score').length).toBeGreaterThan(0);
expect(screen.getAllByText('Threshold').length).toBeGreaterThan(0);
// Sensitive Info sections
expect(screen.getByText('Sensitive Information')).toBeInTheDocument();
expect(screen.getByText('PII Entities')).toBeInTheDocument();
expect(screen.getByText('Custom Regexes')).toBeInTheDocument();
// Topic Policy
expect(screen.getByText('Topic Policy')).toBeInTheDocument();
expect(screen.getByText('weapons')).toBeInTheDocument();
// Invocation Metrics
expect(screen.getByText('Invocation Metrics')).toBeInTheDocument();
// Raw JSON section exists (closed by default)
expect(screen.getByText('Raw Bedrock Guardrail Response')).toBeInTheDocument();
});
it('handles non-text outputs gracefully', () => {
const resp = makeBedrockResponse({ outputs: [{}, { text: 'texty' }] });
renderWithProviders(<BedrockGuardrailDetails response={resp} />);
expect(screen.getByText('(non-text output)')).toBeInTheDocument();
expect(screen.getByText('texty')).toBeInTheDocument();
});
it('gracefully handles missing optional sections', () => {
const resp = makeBedrockResponse({
assessments: [
{
// only include minimal fields; others omitted
invocationMetrics: { guardrailProcessingLatency: 5 },
} as any,
],
usage: undefined,
guardrailCoverage: undefined,
outputs: [],
});
renderWithProviders(<BedrockGuardrailDetails response={resp} />);
// No crash, minimal render: Assessment + Invocation Metrics present, but no usage/coverage chips at top
expect(screen.getByText('Assessment #1')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,508 @@
import React, { useState } from "react";
export type BedrockGuardrailAction = "NONE" | "GUARDRAIL_INTERVENED";
export interface BedrockGuardrailUsage {
wordPolicyUnits?: number;
topicPolicyUnits?: number;
contentPolicyUnits?: number;
contentPolicyImageUnits?: number;
automatedReasoningPolicies?: number;
automatedReasoningPolicyUnits?: number;
contextualGroundingPolicyUnits?: number;
sensitiveInformationPolicyUnits?: number;
sensitiveInformationPolicyFreeUnits?: number;
}
export interface BedrockGuardrailCoverageDim {
total?: number;
guarded?: number;
}
export interface BedrockGuardrailCoverage {
images?: BedrockGuardrailCoverageDim;
textCharacters?: BedrockGuardrailCoverageDim;
}
export interface BedrockWordItem {
action?: string; // e.g., "BLOCKED" or "ALLOWED"
detected?: boolean;
match?: string;
type?: string; // present on managedWordLists entries
}
export interface BedrockContentFilter {
type?: string; // e.g. "HATE", etc.
action?: string; // "BLOCKED" | "NONE" (varies by config)
detected?: boolean;
filterStrength?: string; // e.g., "HIGH"/"MEDIUM"/"LOW"
confidence?: string; // e.g., "HIGH"/"MEDIUM"/"LOW"
}
export interface BedrockContextualGroundingFilter {
type?: string; // "GROUNDING" | "RELEVANCE"
action?: string; // "BLOCKED" | ...
detected?: boolean;
score?: number; // model score
threshold?: number; // configured threshold
}
export interface BedrockPiiEntity {
type?: string; // e.g., "ADDRESS", "EMAIL", etc.
match?: string; // the matched text (may be masked)
detected?: boolean;
action?: string; // "BLOCKED" | "ANONYMIZED" | ...
}
export interface BedrockRegexFinding {
name?: string;
regex?: string;
match?: string;
detected?: boolean;
action?: string;
}
export interface BedrockTopic {
name?: string; // topic list entry name
type?: string; // "DENY" | "ALLOW" depending on list
detected?: boolean;
action?: string; // "BLOCKED" | "NONE"
}
export interface BedrockInvocationMetrics {
usage?: BedrockGuardrailUsage;
guardrailCoverage?: BedrockGuardrailCoverage;
guardrailProcessingLatency?: number; // in ms
}
export interface BedrockAssessment {
wordPolicy?: { customWords?: BedrockWordItem[]; managedWordLists?: BedrockWordItem[] };
contentPolicy?: { filters?: BedrockContentFilter[] };
topicPolicy?: { topics?: BedrockTopic[] };
sensitiveInformationPolicy?: { piiEntities?: BedrockPiiEntity[]; regexes?: BedrockRegexFinding[] };
contextualGroundingPolicy?: { filters?: BedrockContextualGroundingFilter[] };
automatedReasoningPolicy?: { findings?: any[] };
invocationMetrics?: BedrockInvocationMetrics;
}
export interface BedrockOutputContent {
text?: string;
}
export interface BedrockGuardrailResponse {
action?: BedrockGuardrailAction;
actionReason?: string | null;
outputs?: BedrockOutputContent[];
output?: BedrockOutputContent[];
usage?: BedrockGuardrailUsage;
guardrailCoverage?: BedrockGuardrailCoverage;
assessments?: BedrockAssessment[];
blockedResponse?: string;
}
/** ====== UI helpers ====== */
type ChipTone = "green" | "red" | "blue" | "slate" | "amber";
const chip = (
text: React.ReactNode,
tone: ChipTone = "slate"
) => {
const map: Record<ChipTone, string> = {
green: "bg-green-100 text-green-800",
red: "bg-red-100 text-red-800",
blue: "bg-blue-50 text-blue-700",
slate: "bg-slate-100 text-slate-800",
amber: "bg-amber-100 text-amber-800",
};
return <span className={`px-2 py-1 rounded-md text-xs font-medium inline-block ${map[tone]}`}>{text}</span>;
};
const boolPill = (b?: boolean) => (b ? chip("detected", "red") : chip("not detected", "slate"));
interface SectionProps {
title: string;
count?: number;
defaultOpen?: boolean;
right?: React.ReactNode;
children?: React.ReactNode;
}
const Section: React.FC<SectionProps> = ({
title,
count,
defaultOpen = true,
right,
children,
}) => {
const [open, setOpen] = useState(defaultOpen);
return (
<div className="border rounded-lg overflow-hidden">
<div
className="flex items-center justify-between p-3 bg-gray-50 cursor-pointer hover:bg-gray-100"
onClick={() => setOpen((v) => !v)}
>
<div className="flex items-center">
<svg className={`w-5 h-5 mr-2 transition-transform ${open ? "transform rotate-90" : ""}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
<h5 className="font-medium">
{title} {typeof count === "number" && <span className="text-gray-500 font-normal">({count})</span>}
</h5>
</div>
<div>{right}</div>
</div>
{open && <div className="p-3 border-t bg-white">{children}</div>}
</div>
);
};
interface KVProps {
label: string;
children?: React.ReactNode;
mono?: boolean;
}
const KV: React.FC<KVProps> = ({ label, children, mono }) => (
<div className="flex">
<span className="font-medium w-1/3">{label}</span>
<span className={mono ? "font-mono text-sm break-all" : ""}>{children}</span>
</div>
);
const Divider: React.FC = () => <div className="my-3 border-t" />;
/** ====== Main component ====== */
export const BedrockGuardrailDetails: React.FC<{ response: BedrockGuardrailResponse }> = ({ response }) => {
if (!response) return null;
const outputs: BedrockOutputContent[] = (response.outputs ?? response.output ?? []) as BedrockOutputContent[];
const actionTone: "green" | "red" = response.action === "GUARDRAIL_INTERVENED" ? "red" : "green";
const coverageChips = (
<div className="flex flex-wrap gap-2">
{response.guardrailCoverage?.textCharacters && (
chip(
`text guarded ${response.guardrailCoverage.textCharacters.guarded ?? 0}/${response.guardrailCoverage.textCharacters.total ?? 0}`,
"blue"
)
)}
{response.guardrailCoverage?.images && (
chip(
`images guarded ${response.guardrailCoverage.images.guarded ?? 0}/${response.guardrailCoverage.images.total ?? 0}`,
"blue"
)
)}
</div>
);
const usagePills = response.usage && (
<div className="flex flex-wrap gap-2">
{Object.entries(response.usage).map(([k, v]) =>
typeof v === "number" ? (
<span key={k} className="px-2 py-1 bg-slate-100 text-slate-800 rounded-md text-xs font-medium">
{k}: {v}
</span>
) : null
)}
</div>
);
return (
<div className="space-y-3">
{/* Top summary card */}
<div className="border rounded-lg p-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<KV label="Action:">
{chip(response.action ?? "N/A", actionTone)}
</KV>
{response.actionReason && <KV label="Action Reason:">{response.actionReason}</KV>}
{response.blockedResponse && (
<KV label="Blocked Response:">
<span className="italic">{response.blockedResponse}</span>
</KV>
)}
</div>
<div className="space-y-2">
<KV label="Coverage:">{coverageChips}</KV>
<KV label="Usage:">{usagePills}</KV>
</div>
</div>
{/* Outputs */}
{outputs.length > 0 && (
<>
<Divider />
<h4 className="font-medium mb-2">Outputs</h4>
<div className="space-y-2">
{outputs.map((o, i) => (
<div key={i} className="p-3 bg-gray-50 rounded-md">
<div className="text-sm whitespace-pre-wrap">{o.text ?? <em>(non-text output)</em>}</div>
</div>
))}
</div>
</>
)}
</div>
{/* Assessments */}
{response.assessments?.length ? (
<div className="space-y-3">
{response.assessments.map((assess, idx) => {
const policyBadges = (
<div className="flex flex-wrap gap-1">
{assess.wordPolicy && chip("word", "slate")}
{assess.contentPolicy && chip("content", "slate")}
{assess.topicPolicy && chip("topic", "slate")}
{assess.sensitiveInformationPolicy && chip("sensitive-info", "slate")}
{assess.contextualGroundingPolicy && chip("contextual-grounding", "slate")}
{assess.automatedReasoningPolicy && chip("automated-reasoning", "slate")}
</div>
);
return (
<Section
key={idx}
title={`Assessment #${idx + 1}`}
defaultOpen
right={
<div className="flex items-center gap-3">
{assess.invocationMetrics?.guardrailProcessingLatency != null &&
chip(`${assess.invocationMetrics.guardrailProcessingLatency} ms`, "amber")}
{policyBadges}
</div>
}
>
{/* Word policy */}
{assess.wordPolicy && (
<div className="mb-3">
<h6 className="font-medium mb-2">Word Policy</h6>
{(assess.wordPolicy.customWords?.length ?? 0) > 0 && (
<Section title="Custom Words" defaultOpen>
<div className="space-y-2">
{assess.wordPolicy.customWords!.map((w, i) => (
<div key={i} className="flex justify-between items-center p-2 bg-gray-50 rounded">
<div className="flex items-center gap-2">
{chip(w.action ?? "N/A", w.detected ? "red" : "slate")}
<span className="font-mono text-sm break-all">{w.match}</span>
</div>
{boolPill(w.detected)}
</div>
))}
</div>
</Section>
)}
{(assess.wordPolicy.managedWordLists?.length ?? 0) > 0 && (
<Section title="Managed Word Lists" defaultOpen={false}>
<div className="space-y-2">
{assess.wordPolicy.managedWordLists!.map((w, i) => (
<div key={i} className="flex justify-between items-center p-2 bg-gray-50 rounded">
<div className="flex items-center gap-2">
{chip(w.action ?? "N/A", w.detected ? "red" : "slate")}
<span className="font-mono text-sm break-all">{w.match}</span>
{w.type && chip(w.type, "slate")}
</div>
{boolPill(w.detected)}
</div>
))}
</div>
</Section>
)}
</div>
)}
{/* Content policy */}
{assess.contentPolicy?.filters?.length ? (
<div className="mb-3">
<h6 className="font-medium mb-2">Content Policy</h6>
<div className="overflow-x-auto">
<table className="min-w-full text-sm">
<thead>
<tr className="text-left text-gray-600">
<th className="py-1 pr-4">Type</th>
<th className="py-1 pr-4">Action</th>
<th className="py-1 pr-4">Detected</th>
<th className="py-1 pr-4">Strength</th>
<th className="py-1 pr-4">Confidence</th>
</tr>
</thead>
<tbody>
{assess.contentPolicy.filters!.map((f, i) => (
<tr key={i} className="border-t">
<td className="py-1 pr-4">{f.type ?? "—"}</td>
<td className="py-1 pr-4">{chip(f.action ?? "—", f.detected ? "red" : "slate")}</td>
<td className="py-1 pr-4">{boolPill(f.detected)}</td>
<td className="py-1 pr-4">{f.filterStrength ?? "—"}</td>
<td className="py-1 pr-4">{f.confidence ?? "—"}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
) : null}
{/* Contextual grounding */}
{assess.contextualGroundingPolicy?.filters?.length ? (
<div className="mb-3">
<h6 className="font-medium mb-2">Contextual Grounding</h6>
<div className="overflow-x-auto">
<table className="min-w-full text-sm">
<thead>
<tr className="text-left text-gray-600">
<th className="py-1 pr-4">Type</th>
<th className="py-1 pr-4">Action</th>
<th className="py-1 pr-4">Detected</th>
<th className="py-1 pr-4">Score</th>
<th className="py-1 pr-4">Threshold</th>
</tr>
</thead>
<tbody>
{assess.contextualGroundingPolicy.filters!.map((f, i) => (
<tr key={i} className="border-t">
<td className="py-1 pr-4">{f.type ?? "—"}</td>
<td className="py-1 pr-4">{chip(f.action ?? "—", f.detected ? "red" : "slate")}</td>
<td className="py-1 pr-4">{boolPill(f.detected)}</td>
<td className="py-1 pr-4">{f.score ?? "—"}</td>
<td className="py-1 pr-4">{f.threshold ?? "—"}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
) : null}
{/* Sensitive Information */}
{assess.sensitiveInformationPolicy && (
<div className="mb-3">
<h6 className="font-medium mb-2">Sensitive Information</h6>
{(assess.sensitiveInformationPolicy.piiEntities?.length ?? 0) > 0 && (
<Section title="PII Entities" defaultOpen>
<div className="space-y-2">
{assess.sensitiveInformationPolicy.piiEntities!.map((p, i) => (
<div key={i} className="flex justify-between items-center p-2 bg-gray-50 rounded">
<div className="flex items-center gap-2">
{chip(p.action ?? "N/A", p.detected ? "red" : "slate")}
{p.type && chip(p.type, "slate")}
<span className="font-mono text-xs break-all">{p.match}</span>
</div>
{boolPill(p.detected)}
</div>
))}
</div>
</Section>
)}
{(assess.sensitiveInformationPolicy.regexes?.length ?? 0) > 0 && (
<Section title="Custom Regexes" defaultOpen={false}>
<div className="space-y-2">
{assess.sensitiveInformationPolicy.regexes!.map((r, i) => (
<div key={i} className="flex flex-col sm:flex-row sm:items-center sm:justify-between p-2 bg-gray-50 rounded gap-1">
<div className="flex items-center gap-2">
{chip(r.action ?? "N/A", r.detected ? "red" : "slate")}
<span className="font-medium">{r.name ?? "regex"}</span>
<span className="font-mono text-xs break-all">{r.regex}</span>
</div>
<div className="flex items-center gap-2">
{boolPill(r.detected)}
{r.match && <span className="font-mono text-xs break-all">{r.match}</span>}
</div>
</div>
))}
</div>
</Section>
)}
</div>
)}
{/* Topic policy */}
{assess.topicPolicy?.topics?.length ? (
<div className="mb-3">
<h6 className="font-medium mb-2">Topic Policy</h6>
<div className="flex flex-wrap gap-2">
{assess.topicPolicy.topics!.map((t, i) => (
<div key={i} className="px-3 py-1.5 bg-gray-50 rounded-md text-xs">
<div className="flex items-center gap-2">
{chip(t.action ?? "N/A", t.detected ? "red" : "slate")}
<span className="font-medium">{t.name ?? "topic"}</span>
{t.type && chip(t.type, "slate")}
{boolPill(t.detected)}
</div>
</div>
))}
</div>
</div>
) : null}
{/* Invocation metrics */}
{assess.invocationMetrics && (
<Section title="Invocation Metrics" defaultOpen={false}>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<KV label="Latency (ms)">{assess.invocationMetrics.guardrailProcessingLatency ?? "—"}</KV>
<KV label="Coverage:">
<div className="flex flex-wrap gap-2">
{assess.invocationMetrics.guardrailCoverage?.textCharacters &&
chip(
`text ${assess.invocationMetrics.guardrailCoverage.textCharacters.guarded ?? 0}/${
assess.invocationMetrics.guardrailCoverage.textCharacters.total ?? 0
}`,
"blue"
)}
{assess.invocationMetrics.guardrailCoverage?.images &&
chip(
`images ${assess.invocationMetrics.guardrailCoverage.images.guarded ?? 0}/${
assess.invocationMetrics.guardrailCoverage.images.total ?? 0
}`,
"blue"
)}
</div>
</KV>
</div>
<div className="space-y-2">
<KV label="Usage:">
<div className="flex flex-wrap gap-2">
{assess.invocationMetrics.usage &&
Object.entries(assess.invocationMetrics.usage).map(([k, v]) =>
typeof v === "number" ? (
<span key={k} className="px-2 py-1 bg-slate-100 text-slate-800 rounded-md text-xs font-medium">
{k}: {v}
</span>
) : null
)}
</div>
</KV>
</div>
</div>
</Section>
)}
{/* Automated reasoning (fallback render) */}
{assess.automatedReasoningPolicy?.findings?.length ? (
<Section title="Automated Reasoning Findings" defaultOpen={false}>
<div className="space-y-2">
{assess.automatedReasoningPolicy.findings!.map((f, i) => (
<pre key={i} className="bg-gray-50 rounded p-2 text-xs overflow-x-auto">
{JSON.stringify(f, null, 2)}
</pre>
))}
</div>
</Section>
) : null}
</Section>
);
})}
</div>
) : null}
{/* Raw JSON (for debugging / completeness) */}
<Section title="Raw Bedrock Guardrail Response" defaultOpen={false}>
<pre className="bg-gray-50 rounded p-3 text-xs overflow-x-auto">{JSON.stringify(response, null, 2)}</pre>
</Section>
</div>
);
};
export default BedrockGuardrailDetails;

View file

@ -0,0 +1,154 @@
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import userEvent from '@testing-library/user-event';
import { renderWithProviders, screen } from "../../../../tests/test-utils"
import {
makeBedrockResponse, makeEntity,
makeGuardrailInformation,
} from "@/components/view_logs/GuardrailViewer/__tests__/fixtures"
import GuardrailViewer from "@/components/view_logs/GuardrailViewer/GuardrailViewer"
// We will mock child components selectively for some tests to assert prop passthrough,
// but also run an integration-style render without mocks.
const PresidioPath = '@/components/view_logs/GuardrailViewer/PresidioDetectedEntities';
const BedrockPath = '@/components/view_logs/GuardrailViewer/BedrockGuardrailDetails';
describe('GuardrailViewer', () => {
beforeEach(() => {
vi.resetModules();
});
it('shows header, status pill color, duration rounding, and time labels', () => {
const data = makeGuardrailInformation({ duration: 1.23456, guardrail_status: 'success' });
renderWithProviders(<GuardrailViewer data={data} />);
expect(screen.getByText('Guardrail Information')).toBeInTheDocument();
// header status pill (success => green)
const statusBadges = screen.getAllByText('success');
// there are two status locations: header chip and grid "Status"
expect(statusBadges.length).toBeGreaterThanOrEqual(1);
// Quick class assertion for at least one of them
expect(statusBadges[0].className).toMatch(/bg-green-100/);
// duration displays with 4 decimals
expect(screen.getByText(/1\.2346s/)).toBeInTheDocument();
// time labels exist
expect(screen.getByText('Start Time:')).toBeInTheDocument();
expect(screen.getByText('End Time:')).toBeInTheDocument();
});
it('calculates and displays masked entity totals with pluralization', () => {
const data = makeGuardrailInformation({
masked_entity_count: { EMAIL_ADDRESS: 2, PHONE_NUMBER: 1 },
});
renderWithProviders(<GuardrailViewer data={data} />);
expect(screen.getByText('3 masked entities')).toBeInTheDocument();
// summary chips for each entry
expect(screen.getByText('EMAIL_ADDRESS: 2')).toBeInTheDocument();
expect(screen.getByText('PHONE_NUMBER: 1')).toBeInTheDocument();
});
it('hides masked badge & summary when count is zero/empty', () => {
const data = makeGuardrailInformation({ masked_entity_count: {} });
renderWithProviders(<GuardrailViewer data={data} />);
expect(screen.queryByText(/masked entity/)).not.toBeInTheDocument();
expect(screen.queryByText('Masked Entity Summary')).not.toBeInTheDocument();
});
it('toggles main section open/closed and chevron rotation class', async () => {
const user = userEvent.setup();
const data = makeGuardrailInformation();
renderWithProviders(<GuardrailViewer data={data} />);
const header = screen.getByText('Guardrail Information').closest('div')!;
// Initially expanded
expect(screen.getByText('Click to collapse')).toBeInTheDocument();
// Click to collapse
await user.click(header);
expect(screen.getByText('Click to expand')).toBeInTheDocument();
// Details gone
expect(screen.queryByText('Masked Entity Summary')).not.toBeInTheDocument();
// Click to expand again
await user.click(header);
expect(screen.getByText('Click to collapse')).toBeInTheDocument();
});
it('defaults to presidio provider when guardrail_provider is undefined', async () => {
vi.doMock(PresidioPath, () => ({
__esModule: true,
default: ({ entities }: any) => <div data-testid="presidio-mock">presidio {entities?.length}</div>,
}));
const { default: Component } = await import('@/components/view_logs/GuardrailViewer/GuardrailViewer');
const data = makeGuardrailInformation({
guardrail_provider: undefined,
guardrail_response: [makeEntity(), makeEntity()],
});
renderWithProviders(<Component data={data} />);
expect(screen.getByTestId('presidio-mock')).toHaveTextContent('presidio 2');
});
it('renders PresidioDetectedEntities when provider="presidio" and response has entities', async () => {
vi.doMock(PresidioPath, () => ({
__esModule: true,
default: ({ entities }: any) => <div data-testid="presidio-mock">count:{entities?.length}</div>,
}));
const { default: Component } = await import('@/components/view_logs/GuardrailViewer/GuardrailViewer');
const data = makeGuardrailInformation({
guardrail_provider: 'presidio',
guardrail_response: [makeEntity()],
});
renderWithProviders(<Component data={data} />);
expect(screen.getByTestId('presidio-mock')).toHaveTextContent('count:1');
});
it('renders BedrockGuardrailDetails when provider="bedrock"', async () => {
vi.doMock(BedrockPath, () => ({
__esModule: true,
default: ({ response }: any) => (
<div data-testid="bedrock-mock">{response?.action ?? 'no-action'}</div>
),
}));
const { default: Component } = await import('@/components/view_logs/GuardrailViewer/GuardrailViewer');
const data = makeGuardrailInformation({
guardrail_provider: 'bedrock',
guardrail_response: makeBedrockResponse({ action: 'GUARDRAIL_INTERVENED' }),
});
renderWithProviders(<Component data={data} />);
expect(screen.getByTestId('bedrock-mock')).toHaveTextContent('GUARDRAIL_INTERVENED');
});
it('unknown provider renders neither Presidio nor Bedrock details', () => {
const data = makeGuardrailInformation({
guardrail_provider: 'unknown',
});
renderWithProviders(<GuardrailViewer data={data} />);
// Summary still present
expect(screen.getByText('Guardrail Information')).toBeInTheDocument();
// No provider sections
expect(screen.queryByText(/Detected Entities/)).not.toBeInTheDocument();
expect(screen.queryByText(/Raw Bedrock Guardrail Response/)).not.toBeInTheDocument();
});
it('integration: renders with real Bedrock details without mocks', () => {
const data = makeGuardrailInformation({
guardrail_provider: 'bedrock',
guardrail_response: makeBedrockResponse({
action: 'NONE',
outputs: [{ text: 'ok' }],
}),
});
renderWithProviders(<GuardrailViewer data={data} />);
// Bedrock summary bits
expect(screen.getByText('Outputs')).toBeInTheDocument();
expect(screen.getByText('ok')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,163 @@
import React, { useState } from "react";
import PresidioDetectedEntities from "./PresidioDetectedEntities";
import BedrockGuardrailDetails, {
BedrockGuardrailResponse,
} from "@/components/view_logs/GuardrailViewer/BedrockGuardrailDetails"
interface RecognitionMetadata {
recognizer_name: string;
recognizer_identifier: string;
}
interface GuardrailEntity {
end: number;
score: number;
start: number;
entity_type: string;
analysis_explanation: string | null;
recognition_metadata: RecognitionMetadata;
}
interface MaskedEntityCount {
[key: string]: number;
}
interface GuardrailInformation {
duration: number;
end_time: number;
start_time: number;
guardrail_mode: string;
guardrail_name: string;
guardrail_status: string;
guardrail_response: GuardrailEntity[] | BedrockGuardrailResponse;
masked_entity_count: MaskedEntityCount;
guardrail_provider?: string; // "presidio" | other providers
}
interface GuardrailViewerProps {
data: GuardrailInformation;
}
const GuardrailViewer = ({ data }: GuardrailViewerProps) => {
const [sectionExpanded, setSectionExpanded] = useState(true);
// Default to presidio for backwards compatibility
const guardrailProvider = data.guardrail_provider ?? "presidio";
if (!data) {
return null;
}
// Calculate total masked entities
const totalMaskedEntities = data.masked_entity_count ?
Object.values(data.masked_entity_count).reduce((sum, count) => sum + count, 0) : 0;
const formatTime = (timestamp: number): string => {
const date = new Date(timestamp * 1000);
return date.toLocaleString();
};
return (
<div className="bg-white rounded-lg shadow mb-6">
<div
className="flex justify-between items-center p-4 border-b cursor-pointer hover:bg-gray-50"
onClick={() => setSectionExpanded(!sectionExpanded)}
>
<div className="flex items-center">
<svg
className={`w-5 h-5 mr-2 text-gray-600 transition-transform ${sectionExpanded ? 'transform rotate-90' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
<h3 className="text-lg font-medium">Guardrail Information</h3>
<span className={`ml-3 px-2 py-1 rounded-md text-xs font-medium inline-block ${
data.guardrail_status === "success"
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}`}>
{data.guardrail_status}
</span>
{totalMaskedEntities > 0 && (
<span className="ml-3 px-2 py-1 bg-blue-50 text-blue-700 rounded-md text-xs font-medium">
{totalMaskedEntities} masked {totalMaskedEntities === 1 ? 'entity' : 'entities'}
</span>
)}
</div>
<span className="text-sm text-gray-500">{sectionExpanded ? 'Click to collapse' : 'Click to expand'}</span>
</div>
{sectionExpanded && (
<div className="p-4">
<div className="bg-white rounded-lg border p-4 mb-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<div className="flex">
<span className="font-medium w-1/3">Guardrail Name:</span>
<span className="font-mono">{data.guardrail_name}</span>
</div>
<div className="flex">
<span className="font-medium w-1/3">Mode:</span>
<span className="font-mono">{data.guardrail_mode}</span>
</div>
<div className="flex">
<span className="font-medium w-1/3">Status:</span>
<span className={`px-2 py-1 rounded-md text-xs font-medium inline-block ${
data.guardrail_status === "success"
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}`}>
{data.guardrail_status}
</span>
</div>
</div>
<div className="space-y-2">
<div className="flex">
<span className="font-medium w-1/3">Start Time:</span>
<span>{formatTime(data.start_time)}</span>
</div>
<div className="flex">
<span className="font-medium w-1/3">End Time:</span>
<span>{formatTime(data.end_time)}</span>
</div>
<div className="flex">
<span className="font-medium w-1/3">Duration:</span>
<span>{data.duration.toFixed(4)}s</span>
</div>
</div>
</div>
{/* Masked Entity Summary */}
{data.masked_entity_count && Object.keys(data.masked_entity_count).length > 0 && (
<div className="mt-4 pt-4 border-t">
<h4 className="font-medium mb-2">Masked Entity Summary</h4>
<div className="flex flex-wrap gap-2">
{Object.entries(data.masked_entity_count).map(([entityType, count]) => (
<span key={entityType} className="px-3 py-1.5 bg-blue-50 text-blue-700 rounded-md text-xs font-medium">
{entityType}: {count}
</span>
))}
</div>
</div>
)}
</div>
{/* Provider-specific Detected Entities */}
{guardrailProvider === "presidio" && (data.guardrail_response as GuardrailEntity[])?.length > 0 && (
<PresidioDetectedEntities entities={data.guardrail_response as GuardrailEntity[]} />
)}
{guardrailProvider === "bedrock" && data.guardrail_response && (
<div className="mt-4">
<BedrockGuardrailDetails response={data.guardrail_response as BedrockGuardrailResponse} />
</div>
)}
</div>
)}
</div>
);
}
export default GuardrailViewer;

View file

@ -0,0 +1,55 @@
import React from 'react';
import { describe, it, expect } from 'vitest';
import userEvent from '@testing-library/user-event';
import PresidioDetectedEntities from '@/components/view_logs/GuardrailViewer/PresidioDetectedEntities';
import { renderWithProviders, screen } from "../../../../tests/test-utils"
import { makeEntity } from "@/components/view_logs/GuardrailViewer/__tests__/fixtures"
describe('PresidioDetectedEntities', () => {
it('renders null when entities empty', () => {
const { container } = renderWithProviders(<PresidioDetectedEntities entities={[]} />);
expect(container).toBeEmptyDOMElement();
});
it('renders per-entity header info including score color and position', async () => {
const user = userEvent.setup();
const e = makeEntity({ start: 10, end: 20, score: 0.92, entity_type: 'EMAIL_ADDRESS' });
renderWithProviders(<PresidioDetectedEntities entities={[e]} />);
// Header row values
expect(screen.getByText('EMAIL_ADDRESS')).toBeInTheDocument();
expect(screen.getByText(/Score: 0\.92/)).toBeInTheDocument();
expect(screen.getByText('Position: 10-20')).toBeInTheDocument();
// Expand details
await user.click(screen.getByText('EMAIL_ADDRESS'));
expect(screen.getByText('Entity Type:')).toBeInTheDocument();
expect(screen.getByText('Characters 10-20')).toBeInTheDocument();
expect(screen.getByText('Confidence:')).toBeInTheDocument();
// Recognizer details
expect(screen.getByText('EmailRecognizer')).toBeInTheDocument();
expect(screen.getByText('email_v1')).toBeInTheDocument();
// Explanation
expect(screen.getByText('Matched via pattern')).toBeInTheDocument();
});
it('handles missing metadata & low scores gracefully', async () => {
const user = userEvent.setup();
const e = makeEntity({
score: 0.3,
recognition_metadata: undefined as any,
analysis_explanation: null,
entity_type: 'NAME',
start: 0,
end: 0,
});
renderWithProviders(<PresidioDetectedEntities entities={[e]} />);
await user.click(screen.getByText('NAME'));
// No recognizer/explanation rows
expect(screen.queryByText('Recognizer:')).not.toBeInTheDocument();
expect(screen.queryByText('Explanation:')).not.toBeInTheDocument();
// Position still renders
expect(screen.getByText('Characters 0-0')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,136 @@
import React, { useState } from "react";
interface RecognitionMetadata {
recognizer_name: string;
recognizer_identifier: string;
}
export interface GuardrailEntity {
end: number;
score: number;
start: number;
entity_type: string;
analysis_explanation: string | null;
recognition_metadata: RecognitionMetadata;
}
interface PresidioDetectedEntitiesProps {
entities: GuardrailEntity[];
}
const getScoreColor = (score: number): string => {
if (score >= 0.8) return "text-green-600";
return "text-yellow-600";
};
const PresidioDetectedEntities = ({ entities }: PresidioDetectedEntitiesProps) => {
const [entityListExpanded, setEntityListExpanded] = useState(true);
const [expandedEntities, setExpandedEntities] = useState<Record<number, boolean>>({});
const toggleEntity = (index: number) => {
setExpandedEntities((prev) => ({
...prev,
[index]: !prev[index],
}));
};
if (!entities || entities.length === 0) return null;
return (
<div className="mt-4">
<div
className="flex items-center mb-2 cursor-pointer"
onClick={() => setEntityListExpanded(!entityListExpanded)}
>
<svg
className={`w-5 h-5 mr-2 transition-transform ${entityListExpanded ? "transform rotate-90" : ""}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
<h4 className="font-medium">Detected Entities ({entities.length})</h4>
</div>
{entityListExpanded && (
<div className="space-y-2">
{entities.map((entity, index) => {
const isExpanded = expandedEntities[index] || false;
return (
<div key={index} className="border rounded-lg overflow-hidden">
<div
className="flex items-center justify-between p-3 bg-gray-50 cursor-pointer hover:bg-gray-100"
onClick={() => toggleEntity(index)}
>
<div className="flex items-center">
<svg
className={`w-5 h-5 mr-2 transition-transform ${isExpanded ? "transform rotate-90" : ""}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
<span className="font-medium mr-2">{entity.entity_type}</span>
<span className={`font-mono ${getScoreColor(entity.score)}`}>
Score: {entity.score.toFixed(2)}
</span>
</div>
<span className="text-xs text-gray-500">Position: {entity.start}-{entity.end}</span>
</div>
{isExpanded && (
<div className="p-3 border-t bg-white">
<div className="grid grid-cols-2 gap-4 mb-2">
<div className="space-y-2">
<div className="flex">
<span className="font-medium w-1/3">Entity Type:</span>
<span>{entity.entity_type}</span>
</div>
<div className="flex">
<span className="font-medium w-1/3">Position:</span>
<span>Characters {entity.start}-{entity.end}</span>
</div>
<div className="flex">
<span className="font-medium w-1/3">Confidence:</span>
<span className={getScoreColor(entity.score)}>{entity.score.toFixed(2)}</span>
</div>
</div>
<div className="space-y-2">
{entity.recognition_metadata && (
<>
<div className="flex">
<span className="font-medium w-1/3">Recognizer:</span>
<span>{entity.recognition_metadata.recognizer_name}</span>
</div>
<div className="flex overflow-hidden">
<span className="font-medium w-1/3">Identifier:</span>
<span className="truncate text-xs font-mono">
{entity.recognition_metadata.recognizer_identifier}
</span>
</div>
</>
)}
{entity.analysis_explanation && (
<div className="flex">
<span className="font-medium w-1/3">Explanation:</span>
<span>{entity.analysis_explanation}</span>
</div>
)}
</div>
</div>
</div>
)}
</div>
);
})}
</div>
)}
</div>
);
}
export default PresidioDetectedEntities;

View file

@ -0,0 +1,119 @@
import type {
BedrockGuardrailResponse,
BedrockAssessment,
BedrockGuardrailCoverage,
BedrockGuardrailUsage,
} from '@/components/view_logs/GuardrailViewer/BedrockGuardrailDetails';
export interface RecognitionMetadata {
recognizer_name: string;
recognizer_identifier: string;
}
export interface GuardrailEntity {
end: number;
score: number;
start: number;
entity_type: string;
analysis_explanation: string | null;
recognition_metadata: RecognitionMetadata;
}
export interface GuardrailInformation {
duration: number;
end_time: number;
start_time: number;
guardrail_mode: string;
guardrail_name: string;
guardrail_status: string;
guardrail_response: GuardrailEntity[] | BedrockGuardrailResponse;
masked_entity_count: Record<string, number>;
guardrail_provider?: string;
}
// ===== Builders =====
export const makeEntity = (overrides: Partial<GuardrailEntity> = {}): GuardrailEntity => ({
end: 18,
start: 5,
score: 0.92,
entity_type: 'EMAIL_ADDRESS',
analysis_explanation: 'Matched via pattern',
recognition_metadata: {
recognizer_name: 'EmailRecognizer',
recognizer_identifier: 'email_v1',
},
...overrides,
});
export const makeMaskedCounts = (overrides: Record<string, number> = {}) => ({
EMAIL_ADDRESS: 2,
PHONE_NUMBER: 1,
...overrides,
});
export const makeBedrockUsage = (overrides: Partial<BedrockGuardrailUsage> = {}): BedrockGuardrailUsage => ({
contentPolicyUnits: 4,
topicPolicyUnits: 2,
...overrides,
});
export const makeBedrockCoverage = (
overrides: Partial<BedrockGuardrailCoverage> = {}
): BedrockGuardrailCoverage => ({
textCharacters: { guarded: 27, total: 100 },
images: { guarded: 1, total: 3 },
...overrides,
});
export const makeAssessment = (overrides: Partial<BedrockAssessment> = {}): BedrockAssessment => ({
wordPolicy: {
customWords: [{ action: 'BLOCKED', detected: true, match: 'badword' }],
managedWordLists: [{ action: 'ALLOWED', detected: false, match: 'ok', type: 'PROFANITY' }],
},
contentPolicy: {
filters: [
{ type: 'HATE', action: 'BLOCKED', detected: true, filterStrength: 'HIGH', confidence: 'MEDIUM' },
{ type: 'VIOLENCE', action: 'NONE', detected: false, filterStrength: 'LOW', confidence: 'LOW' },
],
},
topicPolicy: { topics: [{ name: 'weapons', type: 'DENY', detected: true, action: 'BLOCKED' }] },
sensitiveInformationPolicy: {
piiEntities: [{ type: 'EMAIL', match: 'x@y.com', detected: true, action: 'ANONYMIZED' }],
regexes: [{ name: 'ticket', regex: '#[0-9]+', match: '#123', detected: true, action: 'BLOCKED' }],
},
contextualGroundingPolicy: {
filters: [{ type: 'GROUNDING', action: 'BLOCKED', detected: true, score: 0.2, threshold: 0.5 }],
},
automatedReasoningPolicy: { findings: [{ foo: 'bar' }] },
invocationMetrics: {
guardrailProcessingLatency: 42,
usage: makeBedrockUsage(),
guardrailCoverage: makeBedrockCoverage(),
},
...overrides,
});
export const makeBedrockResponse = (
overrides: Partial<BedrockGuardrailResponse> = {}
): BedrockGuardrailResponse => ({
action: 'NONE',
outputs: [{ text: 'ok' }],
usage: makeBedrockUsage(),
guardrailCoverage: makeBedrockCoverage(),
assessments: [makeAssessment()],
...overrides,
});
export const makeGuardrailInformation = (
overrides: Partial<GuardrailInformation> = {}
): GuardrailInformation => ({
guardrail_name: 'pii-rail',
guardrail_mode: 'post',
guardrail_status: 'success',
start_time: 1_700_000_000,
end_time: 1_700_000_123,
duration: 0.123456,
guardrail_response: [makeEntity()],
masked_entity_count: makeMaskedCounts(),
...overrides,
});

View file

@ -17,7 +17,7 @@ import { KeyResponse, Team } from "../key_team_helpers/key_list"
import KeyInfoView from "../templates/key_info_view"
import { SessionView } from "./SessionView"
import { VectorStoreViewer } from "./VectorStoreViewer"
import { GuardrailViewer } from "./GuardrailViewer"
import GuardrailViewer from "@/components/view_logs/GuardrailViewer/GuardrailViewer"
import FilterComponent from "../molecules/filter"
import { FilterOption } from "../molecules/filter"
import { useLogFilterLogic } from "./log_filter_logic"

View file

@ -0,0 +1,15 @@
import '@testing-library/jest-dom';
import { afterEach, vi } from 'vitest';
import { cleanup } from '@testing-library/react';
afterEach(() => {
cleanup();
});
// Make toLocaleString deterministic in tests; individual tests can override
// This returns ISO-like strings to keep assertions stable.
vi.spyOn(Date.prototype, 'toLocaleString').mockImplementation(function (this: Date, ..._args: unknown[]) {
const d = this;
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
});

View file

@ -0,0 +1,12 @@
import React, { PropsWithChildren } from 'react';
import { render, RenderOptions } from '@testing-library/react';
const Providers: React.FC<PropsWithChildren> = ({ children }) => {
// Add future providers here (Theme/Router/QueryClient/etc.)
return <>{children}</>;
};
export const renderWithProviders = (ui: React.ReactElement, options?: RenderOptions) =>
render(ui, { wrapper: Providers, ...options });
export * from '@testing-library/react';

View file

@ -0,0 +1,17 @@
import { defineConfig } from 'vitest/config'
import { resolve } from "path"
export default defineConfig({
test: {
environment: 'jsdom',
setupFiles: ['tests/setupTests.ts'],
globals: true,
css: true, // lets you import CSS/modules without extra mocks
coverage: { reporter: ['text', 'lcov'] },
},
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
},
},
})