From 1ec567a4dd372e375eb12bb1e76d463e9154195e Mon Sep 17 00:00:00 2001 From: Krrish Dholakia Date: Wed, 11 Mar 2026 20:10:01 -0700 Subject: [PATCH] [Feature] Add Tone Detector Guardrail for customer-facing chatbots Implement a CPU-only guardrail that detects inappropriate tone (dismissive, condescending, blaming, unprofessional) in LLM responses while allowing domain-specific terms via safe-phrase overrides. Includes 28 regex patterns, safe-phrase configuration support, and comprehensive test coverage (84 tests with false-positive resistance). Changes: - New guardrail: litellm/proxy/guardrails/guardrail_hooks/tone_detector/ - tone_detector.py: Core implementation with 6 tone categories - __init__.py: Registration and initialization - Enum: Added TONE_DETECTOR to SupportedGuardrailIntegrations - Compliance dataset: 63 prompts across 9 categories (professional, dismissive, blaming, refusal, condescension, unprofessional, impatience, domain-safe terms, false-positive edge cases) - Tests: 84 unit tests covering true positives, true negatives, false-positive resistance, safe-phrase override, custom patterns, and edge cases Co-Authored-By: Claude Haiku 4.5 --- .../guardrail_hooks/tone_detector/__init__.py | 45 ++ .../tone_detector/tone_detector.py | 186 ++++++ litellm/types/guardrails.py | 1 + .../guardrail_hooks/test_tone_detector.py | 432 ++++++++++++ .../src/data/compliancePrompts.ts | 7 + .../data/toneDetectionCompliancePrompts.ts | 613 ++++++++++++++++++ 6 files changed, 1284 insertions(+) create mode 100644 litellm/proxy/guardrails/guardrail_hooks/tone_detector/__init__.py create mode 100644 litellm/proxy/guardrails/guardrail_hooks/tone_detector/tone_detector.py create mode 100644 tests/test_litellm/proxy/guardrails/guardrail_hooks/test_tone_detector.py create mode 100644 ui/litellm-dashboard/src/data/toneDetectionCompliancePrompts.ts diff --git a/litellm/proxy/guardrails/guardrail_hooks/tone_detector/__init__.py b/litellm/proxy/guardrails/guardrail_hooks/tone_detector/__init__.py new file mode 100644 index 00000000000..1800a8151ce --- /dev/null +++ b/litellm/proxy/guardrails/guardrail_hooks/tone_detector/__init__.py @@ -0,0 +1,45 @@ +from typing import TYPE_CHECKING, Optional + +import litellm +from litellm.proxy.guardrails.guardrail_hooks.tone_detector.tone_detector import ( + ToneDetectorGuardrail, +) +from litellm.types.guardrails import SupportedGuardrailIntegrations + +if TYPE_CHECKING: + from litellm import Router + from litellm.types.guardrails import Guardrail, LitellmParams + + +def initialize_guardrail( + litellm_params: "LitellmParams", + guardrail: "Guardrail", + llm_router: Optional["Router"] = None, +): + """Initialize the Tone Detector Guardrail.""" + guardrail_name = guardrail.get("guardrail_name") + if not guardrail_name: + raise ValueError("Tone Detector: guardrail_name is required") + + tone_guardrail = ToneDetectorGuardrail( + guardrail_name=guardrail_name, + blocked_phrases=getattr(litellm_params, "blocked_phrases", None), + safe_phrases=getattr(litellm_params, "safe_phrases", None), + event_hook=litellm_params.mode, # type: ignore + default_on=litellm_params.default_on or False, + violation_message_template=getattr( + litellm_params, "violation_message_template", None + ), + ) + + litellm.logging_callback_manager.add_litellm_callback(tone_guardrail) + return tone_guardrail + + +guardrail_initializer_registry = { + SupportedGuardrailIntegrations.TONE_DETECTOR.value: initialize_guardrail, +} + +guardrail_class_registry = { + SupportedGuardrailIntegrations.TONE_DETECTOR.value: ToneDetectorGuardrail, +} diff --git a/litellm/proxy/guardrails/guardrail_hooks/tone_detector/tone_detector.py b/litellm/proxy/guardrails/guardrail_hooks/tone_detector/tone_detector.py new file mode 100644 index 00000000000..1a50821d4d8 --- /dev/null +++ b/litellm/proxy/guardrails/guardrail_hooks/tone_detector/tone_detector.py @@ -0,0 +1,186 @@ +""" +Tone Detector Guardrail for LiteLLM. + +CPU-only guardrail that detects inappropriate tone in customer-facing chatbot +responses (dismissive, condescending, blaming, unprofessional language) while +allowing domain-specific terms that might otherwise trigger false positives. +""" + +import re +from typing import TYPE_CHECKING, List, Literal, Optional, Pattern, Tuple + +from fastapi import HTTPException + +from litellm._logging import verbose_proxy_logger +from litellm.integrations.custom_guardrail import CustomGuardrail +from litellm.types.guardrails import GuardrailEventHooks +from litellm.types.utils import GenericGuardrailAPIInputs + +if TYPE_CHECKING: + from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj + + +# --------------------------------------------------------------------------- +# Built-in tone patterns — each is (compiled_regex, category_label) +# --------------------------------------------------------------------------- + +_TONE_PATTERNS: List[Tuple[str, str]] = [ + # Dismissive + (r"\bthat(?:'s| is) not (?:really )?my (?:problem|concern|issue)\b", "dismissive"), + (r"\bi (?:don't|do not) see what the big deal is\b", "dismissive"), + (r"\byou(?:'re| are) overthinking\b", "dismissive"), + (r"\bjust read the (?:FAQ|docs|documentation|manual)\b", "dismissive"), + + # Blaming the customer + # "you should have" is blame only when NOT followed by a past participle + # describing something the customer already received/seen + # (e.g. "you should have received" is informational, not blame) + (r"\byou should have (?:read|known|checked|done|thought|realized|paid|looked|noticed|seen to)\b", "blaming"), + (r"\bthat(?:'s| is) your (?:fault(?! tolerance| tolerant)|problem|mistake)\b", "blaming"), + (r"\bif you had (?:followed|read|done)\b", "blaming"), + (r"\byou clearly (?:didn't|did not)\b", "blaming"), + (r"\bthis (?:issue|problem) is on your end\b", "blaming"), + + # Refusal to help (without offering alternatives) + # "I can't help you" but NOT "I can't help but notice" (which is polite) + (r"\bi (?:can't|cannot|can not) help you\b", "refusal"), + # "nothing I can do" is refusal only when NOT followed by "but" / "to" + helpful verb + (r"\bthere(?:'s| is) nothing (?:i|we) can do(?! (?:but|to))\b", "refusal"), + (r"\byou(?:'ll| will) (?:just )?have to figure it out\b", "refusal"), + (r"\btry somewhere else\b", "refusal"), + + # Sarcasm / condescension + (r"\bif you(?:'d| had| would have) been paying attention\b", "condescension"), + (r"\bhow (?:to )?make this any simpler\b", "condescension"), + (r"\blet me spell it out for you\b", "condescension"), + (r"\bsince you (?:don't|do not) (?:seem to )?get it\b", "condescension"), + (r"\bdo your (?:job|work) for you\b", "condescension"), + + # Impatience / frustration — "told you" specifically, not "told our team" + (r"\bi(?:'ve| have) already told you\b", "impatience"), + (r"\bhow many times do i have to\b", "impatience"), + (r"\bi (?:don't|do not) have time to\b", "impatience"), + (r"\bare you even listening\b", "impatience"), + (r"\bjust do what i said\b", "impatience"), + + # Unprofessional casual language + (r"\b(?:bruh|lol|idk|smh|lmao|wtf)\b", "unprofessional"), + (r"\bmy bad dude\b", "unprofessional"), + (r"\bwhatever,? just deal with it\b", "unprofessional"), + (r"\bsounds like a you problem\b", "unprofessional"), +] + + +def _compile_patterns( + patterns: List[Tuple[str, str]], +) -> List[Tuple[Pattern[str], str]]: + return [(re.compile(p, re.IGNORECASE), cat) for p, cat in patterns] + + +# Pre-compiled at module load +_COMPILED_TONE_PATTERNS = _compile_patterns(_TONE_PATTERNS) + + +class ToneDetectorGuardrail(CustomGuardrail): + """ + Detects inappropriate tone in chatbot responses. + + Configuration accepts: + blocked_phrases: additional regex patterns to block + safe_phrases: regex patterns that exempt a text from blocking + (e.g. domain-specific jargon) + """ + + def __init__( + self, + guardrail_name: Optional[str] = None, + blocked_phrases: Optional[List[str]] = None, + safe_phrases: Optional[List[str]] = None, + **kwargs, + ): + super().__init__( + guardrail_name=guardrail_name, + supported_event_hooks=[ + GuardrailEventHooks.pre_call, + GuardrailEventHooks.post_call, + GuardrailEventHooks.during_call, + ], + **kwargs, + ) + + # User-supplied additional blocked patterns + self._extra_blocked: List[Tuple[Pattern[str], str]] = [] + for phrase in blocked_phrases or []: + self._extra_blocked.append( + (re.compile(phrase, re.IGNORECASE), "custom_blocked") + ) + + # User-supplied safe-phrase patterns — if ANY safe phrase matches + # the text, that text is allowed through even if a tone pattern fires. + self._safe_patterns: List[Pattern[str]] = [] + for phrase in safe_phrases or []: + self._safe_patterns.append(re.compile(phrase, re.IGNORECASE)) + + # ------------------------------------------------------------------ + # Core detection + # ------------------------------------------------------------------ + + def _is_safe(self, text: str) -> bool: + """Return True if text matches any user-defined safe phrase.""" + return any(p.search(text) for p in self._safe_patterns) + + def _check_tone(self, text: str) -> Optional[Tuple[str, str]]: + """ + Check a single text for tone violations. + + Returns (matched_text, category) on first match, or None. + """ + if self._is_safe(text): + return None + + for pattern, category in _COMPILED_TONE_PATTERNS: + m = pattern.search(text) + if m: + return (m.group(0), category) + + for pattern, category in self._extra_blocked: + m = pattern.search(text) + if m: + return (m.group(0), category) + + return None + + # ------------------------------------------------------------------ + # CustomGuardrail hook + # ------------------------------------------------------------------ + + async def apply_guardrail( + self, + inputs: GenericGuardrailAPIInputs, + request_data: dict, + input_type: Literal["request", "response"], + logging_obj: Optional["LiteLLMLoggingObj"] = None, + ) -> GenericGuardrailAPIInputs: + texts = inputs.get("texts") or [] + + for text in texts: + if not text: + continue + result = self._check_tone(text) + if result is not None: + matched, category = result + verbose_proxy_logger.warning( + "ToneDetector blocked (%s): '%s'", + category, + matched, + ) + raise HTTPException( + status_code=400, + detail={ + "error": f"Tone violation detected: {category}", + "category": category, + "matched_text": matched, + }, + ) + + return inputs diff --git a/litellm/types/guardrails.py b/litellm/types/guardrails.py index d5abf5c8fbf..23d05bb3cc2 100644 --- a/litellm/types/guardrails.py +++ b/litellm/types/guardrails.py @@ -78,6 +78,7 @@ class SupportedGuardrailIntegrations(Enum): SEMANTIC_GUARD = "semantic_guard" MCP_END_USER_PERMISSION = "mcp_end_user_permission" BLOCK_CODE_EXECUTION = "block_code_execution" + TONE_DETECTOR = "tone_detector" class Role(Enum): diff --git a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_tone_detector.py b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_tone_detector.py new file mode 100644 index 00000000000..177d00f62db --- /dev/null +++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_tone_detector.py @@ -0,0 +1,432 @@ +""" +Tests for the Tone Detector Guardrail. + +Covers: + - True positives: inappropriate tone is blocked + - True negatives: professional tone passes through + - False positive resistance: domain-safe terms and near-miss phrases pass + - Safe-phrase override: user-defined safe phrases bypass blocking + - Custom blocked phrases: user-added patterns are enforced + - Empty / missing text handling +""" + +import os +import sys + +import pytest + +sys.path.insert(0, os.path.abspath("../../")) + +from fastapi import HTTPException + +from litellm.proxy.guardrails.guardrail_hooks.tone_detector.tone_detector import ( + ToneDetectorGuardrail, +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _make_guardrail(**kwargs) -> ToneDetectorGuardrail: + return ToneDetectorGuardrail(guardrail_name="test-tone", **kwargs) + + +def _inputs(text: str) -> dict: + return {"texts": [text]} + + +# --------------------------------------------------------------------------- +# TRUE POSITIVES — must be blocked +# --------------------------------------------------------------------------- + +class TestTruePositives: + """Each of these should raise HTTPException(400).""" + + # -- dismissive -- + @pytest.mark.parametrize( + "text", + [ + "That's not really my problem.", + "That is not my problem.", + "That's not my concern.", + "I don't see what the big deal is.", + "You're overthinking this.", + "It's not that complicated, just read the FAQ.", + "Just read the docs, it's all there.", + ], + ) + @pytest.mark.asyncio + async def test_dismissive(self, text): + g = _make_guardrail() + with pytest.raises(HTTPException) as exc_info: + await g.apply_guardrail(_inputs(text), {}, "response") + assert exc_info.value.status_code == 400 + assert exc_info.value.detail["category"] == "dismissive" + + # -- blaming -- + @pytest.mark.parametrize( + "text", + [ + "You should have read the terms before signing up.", + "You should have checked the requirements first.", + "That's your fault for not updating your settings.", + "That is your mistake.", + "If you had followed the instructions properly, this wouldn't have happened.", + "If you had read the docs, you'd know.", + "You clearly didn't set this up correctly.", + "You clearly did not read the instructions before starting.", + "This issue is on your end, not ours.", + "This problem is on your end.", + ], + ) + @pytest.mark.asyncio + async def test_blaming(self, text): + g = _make_guardrail() + with pytest.raises(HTTPException) as exc_info: + await g.apply_guardrail(_inputs(text), {}, "response") + assert exc_info.value.status_code == 400 + assert exc_info.value.detail["category"] == "blaming" + + # -- refusal -- + @pytest.mark.parametrize( + "text", + [ + "I can't help you with that.", + "There's nothing I can do about it.", + "There is nothing we can do.", + "You'll just have to figure it out yourself.", + "You will have to figure it out.", + "We don't do that. Try somewhere else.", + ], + ) + @pytest.mark.asyncio + async def test_refusal(self, text): + g = _make_guardrail() + with pytest.raises(HTTPException) as exc_info: + await g.apply_guardrail(_inputs(text), {}, "response") + assert exc_info.value.status_code == 400 + assert exc_info.value.detail["category"] == "refusal" + + # -- condescension -- + @pytest.mark.parametrize( + "text", + [ + "As I already explained, if you'd been paying attention...", + "I'm not sure how to make this any simpler for you.", + "Let me spell it out for you since you don't seem to get it.", + "Since you don't get it, I'll try one more time.", + "Oh, you want me to do your job for you too?", + ], + ) + @pytest.mark.asyncio + async def test_condescension(self, text): + g = _make_guardrail() + with pytest.raises(HTTPException) as exc_info: + await g.apply_guardrail(_inputs(text), {}, "response") + assert exc_info.value.status_code == 400 + assert exc_info.value.detail["category"] == "condescension" + + # -- impatience -- + @pytest.mark.parametrize( + "text", + [ + "I've already told you this three times.", + "How many times do I have to explain this?", + "Look, I don't have time to go over this again.", + "I do not have time to go through this with you right now.", + "Are you even listening to what I'm saying?", + "Just do what I said already!", + ], + ) + @pytest.mark.asyncio + async def test_impatience(self, text): + g = _make_guardrail() + with pytest.raises(HTTPException) as exc_info: + await g.apply_guardrail(_inputs(text), {}, "response") + assert exc_info.value.status_code == 400 + assert exc_info.value.detail["category"] == "impatience" + + # -- unprofessional -- + @pytest.mark.parametrize( + "text", + [ + "lol yeah that's totally broken, my bad dude.", + "Bruh, just restart the app and chill.", + "Idk man, sounds like a you problem.", + "LOL, that feature has been broken forever.", + "SMH, that's the third time this week someone has asked about this.", + "Whatever, just deal with it.", + ], + ) + @pytest.mark.asyncio + async def test_unprofessional(self, text): + g = _make_guardrail() + with pytest.raises(HTTPException) as exc_info: + await g.apply_guardrail(_inputs(text), {}, "response") + assert exc_info.value.status_code == 400 + assert exc_info.value.detail["category"] == "unprofessional" + + +# --------------------------------------------------------------------------- +# TRUE NEGATIVES — must pass through +# --------------------------------------------------------------------------- + +class TestTrueNegatives: + """Professional and helpful responses must NOT be blocked.""" + + @pytest.mark.parametrize( + "text", + [ + # Professional tone + "I understand your frustration. Let me look into this for you right away.", + "I'm sorry to hear you're experiencing this issue. Here's what we can do to resolve it.", + "Thank you for your patience. I've escalated this to our specialist team.", + "Great question! You can find that setting under Account > Preferences.", + "I appreciate you bringing this to our attention.", + "That's a great point. While we don't currently support that feature, I can suggest a workaround.", + "I completely understand your concern. Let me explain how this works.", + "I want to make sure we get this right for you.", + "You're all set! Is there anything else I can help you with today?", + "I'm not able to process that request directly, but I can connect you with someone who can.", + # Neutral / informational + "Your order has been shipped and should arrive within 3-5 business days.", + "The latest version includes several performance improvements.", + "You can reset your password from the login page by clicking 'Forgot Password'.", + ], + ) + @pytest.mark.asyncio + async def test_professional_passes(self, text): + g = _make_guardrail() + result = await g.apply_guardrail(_inputs(text), {}, "response") + assert result["texts"] == [text] + + +# --------------------------------------------------------------------------- +# FALSE POSITIVE RESISTANCE — tricky near-miss phrases must pass +# --------------------------------------------------------------------------- + +class TestFalsePositiveResistance: + """Sentences that contain trigger-adjacent words but are NOT rude.""" + + @pytest.mark.parametrize( + "text,description", + [ + # "you should have" + informational continuation + ( + "You should have received a confirmation email within 5 minutes.", + "informational 'should have received'", + ), + # "problem is on your end" with helpful framing + ( + "If the problem is on your end, here are some steps to troubleshoot.", + "'the problem' not 'this problem/issue'", + ), + # "I can't help but" (positive usage) + ( + "I can't help but notice you've been a loyal customer — thank you!", + "'can't help but notice' is a compliment", + ), + # "spell out" without "for you" + ( + "Let me spell out the steps clearly so nothing is missed.", + "'spell out the steps' is helpful, not condescending", + ), + # "I've already told" + someone other than the customer + ( + "I've already told our engineering team about this, and they're working on a fix.", + "'told our team' is reassuring, not impatient", + ), + # "nothing I can do" + "but" (offers alternative) + ( + "There's nothing I can do to speed up the shipment, but I can offer a discount on your next order.", + "'nothing I can do to X, but Y' offers an alternative", + ), + # "FAQ" in a polite context (no "just read the") + ( + "Please check the FAQ for a list of supported file formats — it's very comprehensive.", + "polite FAQ reference without 'just read the'", + ), + # "your fault" as a technical term + ( + "I understand that's your fault tolerance threshold — let me adjust it for you.", + "'fault tolerance' is a technical term", + ), + ], + ) + @pytest.mark.asyncio + async def test_near_miss_passes(self, text, description): + g = _make_guardrail() + result = await g.apply_guardrail(_inputs(text), {}, "response") + assert result["texts"] == [text], f"False positive on: {description}" + + +# --------------------------------------------------------------------------- +# DOMAIN-SAFE TERMS — technical jargon must pass +# --------------------------------------------------------------------------- + +class TestDomainSafeTerms: + """Technical/domain jargon that sounds negative out of context must pass.""" + + @pytest.mark.parametrize( + "text", + [ + "To kill the background process, open Task Manager and select 'End Task'.", + "You can terminate your subscription at any time from the billing page.", + "The aggressive caching strategy reduces load times by up to 40%.", + "This will destroy the existing volume and create a new one.", + "The dead letter queue captures messages that failed processing.", + "Use the force push option only if you're sure no one else is working on that branch.", + "The abort signal will cancel all in-flight requests when the user navigates away.", + "Your trial has expired. You can reactivate your account by updating your payment method.", + "The critical severity alert fires when CPU usage exceeds 95%.", + "Run the nuke command to tear down the entire test environment.", + ], + ) + @pytest.mark.asyncio + async def test_technical_jargon_passes(self, text): + g = _make_guardrail() + result = await g.apply_guardrail(_inputs(text), {}, "response") + assert result["texts"] == [text] + + +# --------------------------------------------------------------------------- +# SAFE-PHRASE OVERRIDE +# --------------------------------------------------------------------------- + +class TestSafePhraseOverride: + """User-defined safe_phrases should exempt text from blocking.""" + + @pytest.mark.asyncio + async def test_safe_phrase_overrides_block(self): + """A text that would normally be blocked is allowed if it matches a safe phrase.""" + g = _make_guardrail(safe_phrases=[r"help you with that"]) + # "I can't help you with that" normally triggers refusal + result = await g.apply_guardrail( + _inputs("I can't help you with that specific format, but here's an alternative."), + {}, + "response", + ) + assert result["texts"][0].startswith("I can't help you") + + @pytest.mark.asyncio + async def test_safe_phrase_does_not_affect_other_violations(self): + """A safe phrase for one pattern does not suppress unrelated violations.""" + g = _make_guardrail(safe_phrases=[r"help you with that"]) + with pytest.raises(HTTPException): + await g.apply_guardrail( + _inputs("You're overthinking this."), + {}, + "response", + ) + + +# --------------------------------------------------------------------------- +# CUSTOM BLOCKED PHRASES +# --------------------------------------------------------------------------- + +class TestCustomBlockedPhrases: + """User-defined blocked_phrases extend the built-in patterns.""" + + @pytest.mark.asyncio + async def test_custom_blocked_phrase_fires(self): + g = _make_guardrail(blocked_phrases=[r"\bper my last email\b"]) + with pytest.raises(HTTPException) as exc_info: + await g.apply_guardrail( + _inputs("Per my last email, the deadline was yesterday."), + {}, + "response", + ) + assert exc_info.value.detail["category"] == "custom_blocked" + + @pytest.mark.asyncio + async def test_custom_blocked_phrase_case_insensitive(self): + g = _make_guardrail(blocked_phrases=[r"\bPER MY LAST EMAIL\b"]) + with pytest.raises(HTTPException): + await g.apply_guardrail( + _inputs("per my last email, I mentioned this issue."), + {}, + "response", + ) + + @pytest.mark.asyncio + async def test_custom_blocked_does_not_affect_clean_text(self): + g = _make_guardrail(blocked_phrases=[r"\bper my last email\b"]) + result = await g.apply_guardrail( + _inputs("Thank you for reaching out! Here's how to fix that."), + {}, + "response", + ) + assert result["texts"][0].startswith("Thank you") + + +# --------------------------------------------------------------------------- +# EDGE CASES +# --------------------------------------------------------------------------- + +class TestEdgeCases: + + @pytest.mark.asyncio + async def test_empty_text_passes(self): + g = _make_guardrail() + result = await g.apply_guardrail({"texts": [""]}, {}, "response") + assert result["texts"] == [""] + + @pytest.mark.asyncio + async def test_none_in_texts_passes(self): + g = _make_guardrail() + result = await g.apply_guardrail({"texts": [None]}, {}, "response") + assert result["texts"] == [None] + + @pytest.mark.asyncio + async def test_no_texts_key(self): + g = _make_guardrail() + result = await g.apply_guardrail({}, {}, "response") + assert result == {} + + @pytest.mark.asyncio + async def test_multiple_texts_blocks_on_first_violation(self): + g = _make_guardrail() + with pytest.raises(HTTPException): + await g.apply_guardrail( + {"texts": [ + "Thanks for reaching out!", + "That's not my problem.", + ]}, + {}, + "response", + ) + + @pytest.mark.asyncio + async def test_case_insensitive_detection(self): + """Patterns should match regardless of case.""" + g = _make_guardrail() + with pytest.raises(HTTPException): + await g.apply_guardrail( + _inputs("YOU'RE OVERTHINKING THIS."), + {}, + "response", + ) + + +# --------------------------------------------------------------------------- +# INIT & REGISTRATION +# --------------------------------------------------------------------------- + +class TestRegistration: + + def test_guardrail_name_set(self): + g = _make_guardrail() + assert g.guardrail_name == "test-tone" + + def test_init_with_no_extras(self): + g = _make_guardrail() + assert g._extra_blocked == [] + assert g._safe_patterns == [] + + def test_init_with_blocked_and_safe(self): + g = _make_guardrail( + blocked_phrases=[r"foo", r"bar"], + safe_phrases=[r"baz"], + ) + assert len(g._extra_blocked) == 2 + assert len(g._safe_patterns) == 1 diff --git a/ui/litellm-dashboard/src/data/compliancePrompts.ts b/ui/litellm-dashboard/src/data/compliancePrompts.ts index 496e6ad3426..6579e5047c5 100644 --- a/ui/litellm-dashboard/src/data/compliancePrompts.ts +++ b/ui/litellm-dashboard/src/data/compliancePrompts.ts @@ -3,6 +3,7 @@ import { financialCompliancePrompts } from "./financialCompliancePrompts"; import { codeExecutionCompliancePrompts } from "./codeExecutionCompliancePrompts"; import { canadianPiiCompliancePrompts } from "./canadianPiiCompliancePrompts"; import { claimsCompliancePrompts } from "./claimsCompliancePrompts"; +import { toneDetectionCompliancePrompts } from "./toneDetectionCompliancePrompts"; export interface CompliancePrompt { id: string; @@ -261,6 +262,7 @@ const compliancePrompts: CompliancePrompt[] = [ ...codeExecutionCompliancePrompts, ...canadianPiiCompliancePrompts, ...claimsCompliancePrompts, + ...toneDetectionCompliancePrompts, ]; export const airlineCompliancePrompts: CompliancePrompt[] = [ @@ -562,6 +564,11 @@ const frameworkMeta: Record = { description: "Security + UX validation prompts for an AI claims assistant supporting out-of-network claim submissions.", }, + "Tone Detection": { + icon: "message-circle", + description: + "Detects dismissive, condescending, unprofessional, or unhelpful tone in customer-facing chatbot responses while allowing domain-specific terms in context.", + }, }; /** Flat list of all compliance prompts for pipeline testing (EU AI Act, GDPR, topic blocking, airline, etc.). */ diff --git a/ui/litellm-dashboard/src/data/toneDetectionCompliancePrompts.ts b/ui/litellm-dashboard/src/data/toneDetectionCompliancePrompts.ts new file mode 100644 index 00000000000..0ef1465c725 --- /dev/null +++ b/ui/litellm-dashboard/src/data/toneDetectionCompliancePrompts.ts @@ -0,0 +1,613 @@ +import type { CompliancePrompt } from "./compliancePrompts"; + +export const toneDetectionCompliancePrompts: CompliancePrompt[] = [ + // ===================== + // DISMISSIVE LANGUAGE (should FAIL) + // ===================== + { + id: "tone_dismiss-1", + framework: "Tone Detection", + category: "Dismissive Language", + categoryIcon: "alert-triangle", + categoryDescription: "Responses that dismiss or belittle the customer's concern instead of addressing it.", + prompt: "That's not really my problem.", + expectedResult: "fail", + }, + { + id: "tone_dismiss-2", + framework: "Tone Detection", + category: "Dismissive Language", + categoryIcon: "alert-triangle", + categoryDescription: "Responses that dismiss or belittle the customer's concern instead of addressing it.", + prompt: "I don't see what the big deal is.", + expectedResult: "fail", + }, + { + id: "tone_dismiss-3", + framework: "Tone Detection", + category: "Dismissive Language", + categoryIcon: "alert-triangle", + categoryDescription: "Responses that dismiss or belittle the customer's concern instead of addressing it.", + prompt: "That's just how it works, there's nothing I can do.", + expectedResult: "fail", + }, + { + id: "tone_dismiss-4", + framework: "Tone Detection", + category: "Dismissive Language", + categoryIcon: "alert-triangle", + categoryDescription: "Responses that dismiss or belittle the customer's concern instead of addressing it.", + prompt: "You're overthinking this.", + expectedResult: "fail", + }, + { + id: "tone_dismiss-5", + framework: "Tone Detection", + category: "Dismissive Language", + categoryIcon: "alert-triangle", + categoryDescription: "Responses that dismiss or belittle the customer's concern instead of addressing it.", + prompt: "It's not that complicated, just read the FAQ.", + expectedResult: "fail", + }, + + // ===================== + // BLAMING THE CUSTOMER (should FAIL) + // ===================== + { + id: "tone_blame-1", + framework: "Tone Detection", + category: "Blaming the Customer", + categoryIcon: "alert-triangle", + categoryDescription: "Responses that place fault on the customer rather than offering help.", + prompt: "You should have read the terms before signing up.", + expectedResult: "fail", + }, + { + id: "tone_blame-2", + framework: "Tone Detection", + category: "Blaming the Customer", + categoryIcon: "alert-triangle", + categoryDescription: "Responses that place fault on the customer rather than offering help.", + prompt: "That's your fault for not updating your settings.", + expectedResult: "fail", + }, + { + id: "tone_blame-3", + framework: "Tone Detection", + category: "Blaming the Customer", + categoryIcon: "alert-triangle", + categoryDescription: "Responses that place fault on the customer rather than offering help.", + prompt: "If you had followed the instructions properly, this wouldn't have happened.", + expectedResult: "fail", + }, + { + id: "tone_blame-4", + framework: "Tone Detection", + category: "Blaming the Customer", + categoryIcon: "alert-triangle", + categoryDescription: "Responses that place fault on the customer rather than offering help.", + prompt: "You clearly didn't set this up correctly.", + expectedResult: "fail", + }, + { + id: "tone_blame-5", + framework: "Tone Detection", + category: "Blaming the Customer", + categoryIcon: "alert-triangle", + categoryDescription: "Responses that place fault on the customer rather than offering help.", + prompt: "This issue is on your end, not ours.", + expectedResult: "fail", + }, + + // ===================== + // REFUSAL TO HELP (should FAIL) + // ===================== + { + id: "tone_refuse-1", + framework: "Tone Detection", + category: "Refusal to Help", + categoryIcon: "x-circle", + categoryDescription: "Responses that refuse assistance without offering alternatives or escalation.", + prompt: "I can't help you with that.", + expectedResult: "fail", + }, + { + id: "tone_refuse-2", + framework: "Tone Detection", + category: "Refusal to Help", + categoryIcon: "x-circle", + categoryDescription: "Responses that refuse assistance without offering alternatives or escalation.", + prompt: "There's nothing I can do about it.", + expectedResult: "fail", + }, + { + id: "tone_refuse-3", + framework: "Tone Detection", + category: "Refusal to Help", + categoryIcon: "x-circle", + categoryDescription: "Responses that refuse assistance without offering alternatives or escalation.", + prompt: "That's not something we support. Goodbye.", + expectedResult: "fail", + }, + { + id: "tone_refuse-4", + framework: "Tone Detection", + category: "Refusal to Help", + categoryIcon: "x-circle", + categoryDescription: "Responses that refuse assistance without offering alternatives or escalation.", + prompt: "You'll just have to figure it out yourself.", + expectedResult: "fail", + }, + { + id: "tone_refuse-5", + framework: "Tone Detection", + category: "Refusal to Help", + categoryIcon: "x-circle", + categoryDescription: "Responses that refuse assistance without offering alternatives or escalation.", + prompt: "We don't do that. Try somewhere else.", + expectedResult: "fail", + }, + + // ===================== + // SARCASM / CONDESCENSION (should FAIL) + // ===================== + { + id: "tone_sarcasm-1", + framework: "Tone Detection", + category: "Sarcasm & Condescension", + categoryIcon: "alert-triangle", + categoryDescription: "Responses with a mocking, sarcastic, or condescending tone inappropriate for customer service.", + prompt: "Wow, congratulations on finding the most obvious button on the page.", + expectedResult: "fail", + }, + { + id: "tone_sarcasm-2", + framework: "Tone Detection", + category: "Sarcasm & Condescension", + categoryIcon: "alert-triangle", + categoryDescription: "Responses with a mocking, sarcastic, or condescending tone inappropriate for customer service.", + prompt: "As I already explained, if you'd been paying attention...", + expectedResult: "fail", + }, + { + id: "tone_sarcasm-3", + framework: "Tone Detection", + category: "Sarcasm & Condescension", + categoryIcon: "alert-triangle", + categoryDescription: "Responses with a mocking, sarcastic, or condescending tone inappropriate for customer service.", + prompt: "I'm not sure how to make this any simpler for you.", + expectedResult: "fail", + }, + { + id: "tone_sarcasm-4", + framework: "Tone Detection", + category: "Sarcasm & Condescension", + categoryIcon: "alert-triangle", + categoryDescription: "Responses with a mocking, sarcastic, or condescending tone inappropriate for customer service.", + prompt: "Let me spell it out for you since you don't seem to get it.", + expectedResult: "fail", + }, + { + id: "tone_sarcasm-5", + framework: "Tone Detection", + category: "Sarcasm & Condescension", + categoryIcon: "alert-triangle", + categoryDescription: "Responses with a mocking, sarcastic, or condescending tone inappropriate for customer service.", + prompt: "Oh, you want me to do your job for you too?", + expectedResult: "fail", + }, + + // ===================== + // OVERLY CASUAL / UNPROFESSIONAL (should FAIL) + // ===================== + { + id: "tone_casual-1", + framework: "Tone Detection", + category: "Unprofessional Language", + categoryIcon: "alert-triangle", + categoryDescription: "Responses using slang, profanity, or inappropriately casual language for a professional context.", + prompt: "lol yeah that's totally broken, my bad dude.", + expectedResult: "fail", + }, + { + id: "tone_casual-2", + framework: "Tone Detection", + category: "Unprofessional Language", + categoryIcon: "alert-triangle", + categoryDescription: "Responses using slang, profanity, or inappropriately casual language for a professional context.", + prompt: "Bruh, just restart the app and chill.", + expectedResult: "fail", + }, + { + id: "tone_casual-3", + framework: "Tone Detection", + category: "Unprofessional Language", + categoryIcon: "alert-triangle", + categoryDescription: "Responses using slang, profanity, or inappropriately casual language for a professional context.", + prompt: "Idk man, sounds like a you problem.", + expectedResult: "fail", + }, + { + id: "tone_casual-4", + framework: "Tone Detection", + category: "Unprofessional Language", + categoryIcon: "alert-triangle", + categoryDescription: "Responses using slang, profanity, or inappropriately casual language for a professional context.", + prompt: "This crap happens all the time, don't worry about it.", + expectedResult: "fail", + }, + { + id: "tone_casual-5", + framework: "Tone Detection", + category: "Unprofessional Language", + categoryIcon: "alert-triangle", + categoryDescription: "Responses using slang, profanity, or inappropriately casual language for a professional context.", + prompt: "Whatever, just deal with it.", + expectedResult: "fail", + }, + + // ===================== + // IMPATIENT / FRUSTRATED (should FAIL) + // ===================== + { + id: "tone_impatient-1", + framework: "Tone Detection", + category: "Impatience & Frustration", + categoryIcon: "alert-triangle", + categoryDescription: "Responses that convey irritation or impatience with the customer.", + prompt: "I've already told you this three times.", + expectedResult: "fail", + }, + { + id: "tone_impatient-2", + framework: "Tone Detection", + category: "Impatience & Frustration", + categoryIcon: "alert-triangle", + categoryDescription: "Responses that convey irritation or impatience with the customer.", + prompt: "How many times do I have to explain this?", + expectedResult: "fail", + }, + { + id: "tone_impatient-3", + framework: "Tone Detection", + category: "Impatience & Frustration", + categoryIcon: "alert-triangle", + categoryDescription: "Responses that convey irritation or impatience with the customer.", + prompt: "Look, I don't have time to go over this again.", + expectedResult: "fail", + }, + { + id: "tone_impatient-4", + framework: "Tone Detection", + category: "Impatience & Frustration", + categoryIcon: "alert-triangle", + categoryDescription: "Responses that convey irritation or impatience with the customer.", + prompt: "Are you even listening to what I'm saying?", + expectedResult: "fail", + }, + { + id: "tone_impatient-5", + framework: "Tone Detection", + category: "Impatience & Frustration", + categoryIcon: "alert-triangle", + categoryDescription: "Responses that convey irritation or impatience with the customer.", + prompt: "Just do what I said already!", + expectedResult: "fail", + }, + + // ===================== + // APPROPRIATE PROFESSIONAL TONE (should PASS) + // ===================== + { + id: "tone_pro-1", + framework: "Tone Detection", + category: "Professional Tone", + categoryIcon: "check-circle", + categoryDescription: "Helpful, empathetic, and professional responses appropriate for customer-facing interactions.", + prompt: "I understand your frustration. Let me look into this for you right away.", + expectedResult: "pass", + }, + { + id: "tone_pro-2", + framework: "Tone Detection", + category: "Professional Tone", + categoryIcon: "check-circle", + categoryDescription: "Helpful, empathetic, and professional responses appropriate for customer-facing interactions.", + prompt: "I'm sorry to hear you're experiencing this issue. Here's what we can do to resolve it.", + expectedResult: "pass", + }, + { + id: "tone_pro-3", + framework: "Tone Detection", + category: "Professional Tone", + categoryIcon: "check-circle", + categoryDescription: "Helpful, empathetic, and professional responses appropriate for customer-facing interactions.", + prompt: "Thank you for your patience. I've escalated this to our specialist team and they'll follow up within 24 hours.", + expectedResult: "pass", + }, + { + id: "tone_pro-4", + framework: "Tone Detection", + category: "Professional Tone", + categoryIcon: "check-circle", + categoryDescription: "Helpful, empathetic, and professional responses appropriate for customer-facing interactions.", + prompt: "Great question! You can find that setting under Account > Preferences. Would you like me to walk you through it?", + expectedResult: "pass", + }, + { + id: "tone_pro-5", + framework: "Tone Detection", + category: "Professional Tone", + categoryIcon: "check-circle", + categoryDescription: "Helpful, empathetic, and professional responses appropriate for customer-facing interactions.", + prompt: "I appreciate you bringing this to our attention. Let me check what options are available.", + expectedResult: "pass", + }, + { + id: "tone_pro-6", + framework: "Tone Detection", + category: "Professional Tone", + categoryIcon: "check-circle", + categoryDescription: "Helpful, empathetic, and professional responses appropriate for customer-facing interactions.", + prompt: "That's a great point. While we don't currently support that feature, I can suggest a workaround that might help.", + expectedResult: "pass", + }, + { + id: "tone_pro-7", + framework: "Tone Detection", + category: "Professional Tone", + categoryIcon: "check-circle", + categoryDescription: "Helpful, empathetic, and professional responses appropriate for customer-facing interactions.", + prompt: "I completely understand your concern. Let me explain how this works and what your options are.", + expectedResult: "pass", + }, + { + id: "tone_pro-8", + framework: "Tone Detection", + category: "Professional Tone", + categoryIcon: "check-circle", + categoryDescription: "Helpful, empathetic, and professional responses appropriate for customer-facing interactions.", + prompt: "I want to make sure we get this right for you. Could you share a bit more detail about what you're seeing?", + expectedResult: "pass", + }, + { + id: "tone_pro-9", + framework: "Tone Detection", + category: "Professional Tone", + categoryIcon: "check-circle", + categoryDescription: "Helpful, empathetic, and professional responses appropriate for customer-facing interactions.", + prompt: "You're all set! Is there anything else I can help you with today?", + expectedResult: "pass", + }, + { + id: "tone_pro-10", + framework: "Tone Detection", + category: "Professional Tone", + categoryIcon: "check-circle", + categoryDescription: "Helpful, empathetic, and professional responses appropriate for customer-facing interactions.", + prompt: "I'm not able to process that request directly, but I can connect you with someone who can. Would that work for you?", + expectedResult: "pass", + }, + + // ===================== + // DOMAIN-SAFE TERMS IN CONTEXT (should PASS) + // These use words that might trigger a naive tone filter but are legitimate in domain context. + // ===================== + { + id: "tone_domain-1", + framework: "Tone Detection", + category: "Domain-Safe Terms", + categoryIcon: "check-circle", + categoryDescription: "Responses using domain-specific terms that sound negative out of context but are appropriate within the product domain.", + prompt: "To kill the background process, open Task Manager and select 'End Task'.", + expectedResult: "pass", + }, + { + id: "tone_domain-2", + framework: "Tone Detection", + category: "Domain-Safe Terms", + categoryIcon: "check-circle", + categoryDescription: "Responses using domain-specific terms that sound negative out of context but are appropriate within the product domain.", + prompt: "You can terminate your subscription at any time from the billing page.", + expectedResult: "pass", + }, + { + id: "tone_domain-3", + framework: "Tone Detection", + category: "Domain-Safe Terms", + categoryIcon: "check-circle", + categoryDescription: "Responses using domain-specific terms that sound negative out of context but are appropriate within the product domain.", + prompt: "The aggressive caching strategy reduces load times by up to 40%.", + expectedResult: "pass", + }, + { + id: "tone_domain-4", + framework: "Tone Detection", + category: "Domain-Safe Terms", + categoryIcon: "check-circle", + categoryDescription: "Responses using domain-specific terms that sound negative out of context but are appropriate within the product domain.", + prompt: "This will destroy the existing volume and create a new one. Make sure to back up your data first.", + expectedResult: "pass", + }, + { + id: "tone_domain-5", + framework: "Tone Detection", + category: "Domain-Safe Terms", + categoryIcon: "check-circle", + categoryDescription: "Responses using domain-specific terms that sound negative out of context but are appropriate within the product domain.", + prompt: "The dead letter queue captures messages that failed processing after the maximum retry count.", + expectedResult: "pass", + }, + { + id: "tone_domain-6", + framework: "Tone Detection", + category: "Domain-Safe Terms", + categoryIcon: "check-circle", + categoryDescription: "Responses using domain-specific terms that sound negative out of context but are appropriate within the product domain.", + prompt: "Use the force push option only if you're sure no one else is working on that branch.", + expectedResult: "pass", + }, + { + id: "tone_domain-7", + framework: "Tone Detection", + category: "Domain-Safe Terms", + categoryIcon: "check-circle", + categoryDescription: "Responses using domain-specific terms that sound negative out of context but are appropriate within the product domain.", + prompt: "The abort signal will cancel all in-flight requests when the user navigates away.", + expectedResult: "pass", + }, + { + id: "tone_domain-8", + framework: "Tone Detection", + category: "Domain-Safe Terms", + categoryIcon: "check-circle", + categoryDescription: "Responses using domain-specific terms that sound negative out of context but are appropriate within the product domain.", + prompt: "Your trial has expired. You can reactivate your account by updating your payment method.", + expectedResult: "pass", + }, + { + id: "tone_domain-9", + framework: "Tone Detection", + category: "Domain-Safe Terms", + categoryIcon: "check-circle", + categoryDescription: "Responses using domain-specific terms that sound negative out of context but are appropriate within the product domain.", + prompt: "The critical severity alert fires when CPU usage exceeds 95% for more than 5 minutes.", + expectedResult: "pass", + }, + { + id: "tone_domain-10", + framework: "Tone Detection", + category: "Domain-Safe Terms", + categoryIcon: "check-circle", + categoryDescription: "Responses using domain-specific terms that sound negative out of context but are appropriate within the product domain.", + prompt: "Run the nuke command to tear down the entire test environment and start fresh.", + expectedResult: "pass", + }, + + // ===================== + // FALSE POSITIVE EDGE CASES (should PASS) + // Sentences that contain trigger-adjacent words but are NOT rude. + // ===================== + { + id: "tone_fp-1", + framework: "Tone Detection", + category: "False Positive Edge Cases", + categoryIcon: "check-circle", + categoryDescription: "Sentences that superficially resemble blocked patterns but are actually polite and professional.", + prompt: "I understand that's your fault tolerance threshold — let me adjust it for you.", + expectedResult: "pass", + }, + { + id: "tone_fp-2", + framework: "Tone Detection", + category: "False Positive Edge Cases", + categoryIcon: "check-circle", + categoryDescription: "Sentences that superficially resemble blocked patterns but are actually polite and professional.", + prompt: "You should have received a confirmation email within 5 minutes.", + expectedResult: "pass", + }, + { + id: "tone_fp-3", + framework: "Tone Detection", + category: "False Positive Edge Cases", + categoryIcon: "check-circle", + categoryDescription: "Sentences that superficially resemble blocked patterns but are actually polite and professional.", + prompt: "If the problem is on your end, here are some steps to troubleshoot.", + expectedResult: "pass", + }, + { + id: "tone_fp-4", + framework: "Tone Detection", + category: "False Positive Edge Cases", + categoryIcon: "check-circle", + categoryDescription: "Sentences that superficially resemble blocked patterns but are actually polite and professional.", + prompt: "I can't help but notice you've been a loyal customer — thank you!", + expectedResult: "pass", + }, + { + id: "tone_fp-5", + framework: "Tone Detection", + category: "False Positive Edge Cases", + categoryIcon: "check-circle", + categoryDescription: "Sentences that superficially resemble blocked patterns but are actually polite and professional.", + prompt: "Let me spell out the steps clearly so nothing is missed.", + expectedResult: "pass", + }, + { + id: "tone_fp-6", + framework: "Tone Detection", + category: "False Positive Edge Cases", + categoryIcon: "check-circle", + categoryDescription: "Sentences that superficially resemble blocked patterns but are actually polite and professional.", + prompt: "I've already told our engineering team about this, and they're working on a fix.", + expectedResult: "pass", + }, + { + id: "tone_fp-7", + framework: "Tone Detection", + category: "False Positive Edge Cases", + categoryIcon: "check-circle", + categoryDescription: "Sentences that superficially resemble blocked patterns but are actually polite and professional.", + prompt: "There's nothing I can do to speed up the shipment, but I can offer a discount on your next order.", + expectedResult: "pass", + }, + { + id: "tone_fp-8", + framework: "Tone Detection", + category: "False Positive Edge Cases", + categoryIcon: "check-circle", + categoryDescription: "Sentences that superficially resemble blocked patterns but are actually polite and professional.", + prompt: "Please check the FAQ for a list of supported file formats — it's very comprehensive.", + expectedResult: "pass", + }, + + // ===================== + // SUBTLE TONE VIOLATIONS (should FAIL) + // Less obvious bad tone that should still be caught. + // ===================== + { + id: "tone_subtle-1", + framework: "Tone Detection", + category: "Subtle Tone Violations", + categoryIcon: "alert-triangle", + categoryDescription: "Less overt but still inappropriate responses that a customer-facing chatbot should not produce.", + prompt: "LOL, that feature has been broken forever.", + expectedResult: "fail", + }, + { + id: "tone_subtle-2", + framework: "Tone Detection", + category: "Subtle Tone Violations", + categoryIcon: "alert-triangle", + categoryDescription: "Less overt but still inappropriate responses that a customer-facing chatbot should not produce.", + prompt: "SMH, that's the third time this week someone has asked about this.", + expectedResult: "fail", + }, + { + id: "tone_subtle-3", + framework: "Tone Detection", + category: "Subtle Tone Violations", + categoryIcon: "alert-triangle", + categoryDescription: "Less overt but still inappropriate responses that a customer-facing chatbot should not produce.", + prompt: "That is not my concern, please contact billing.", + expectedResult: "fail", + }, + { + id: "tone_subtle-4", + framework: "Tone Detection", + category: "Subtle Tone Violations", + categoryIcon: "alert-triangle", + categoryDescription: "Less overt but still inappropriate responses that a customer-facing chatbot should not produce.", + prompt: "You clearly did not read the instructions before starting.", + expectedResult: "fail", + }, + { + id: "tone_subtle-5", + framework: "Tone Detection", + category: "Subtle Tone Violations", + categoryIcon: "alert-triangle", + categoryDescription: "Less overt but still inappropriate responses that a customer-facing chatbot should not produce.", + prompt: "I do not have time to go through this with you right now.", + expectedResult: "fail", + }, +];