Guardrail - competitor name blocker (#21719)

* feat: add competitor name blocker guardrail

* fix: fix batch test endpoint for compliance playground

* fix(airline.py): add list of all known airlines to airline competitor name detector

prevent competitor discussion on company chatbot

* feat: ui tweaks for prod
This commit is contained in:
Krish Dholakia
2026-02-20 18:52:40 -08:00
committed by GitHub
parent c61dea5af9
commit e8d0afd7cb
55 changed files with 4686 additions and 201 deletions
+27
View File
@@ -24,6 +24,33 @@ guardrails:
guardrail: mcp_end_user_permission
mode: pre_call
default_on: true
- guardrail_name: "airline-competitor-intent"
guardrail_id: "airline-competitor-intent"
litellm_params:
guardrail: litellm_content_filter
mode: pre_call
default_on: false
competitor_intent_config:
brand_self:
- emirates
- ek
competitors:
- qatar airways
- qatar
- etihad
locations:
- qatar
- doha
- doh
competitor_aliases:
qatar airways: [qr, doha airline]
qatar: [qr]
policy:
competitor_comparison: refuse
possible_competitor_comparison: reframe
threshold_high: 0.70
threshold_medium: 0.45
threshold_low: 0.30
mcp_servers:
my_http_server:
@@ -827,6 +827,42 @@ async def get_category_yaml(category_name: str):
)
@router.get(
"/guardrails/ui/major_airlines",
tags=["Guardrails"],
dependencies=[Depends(user_api_key_auth)],
)
async def get_major_airlines():
"""
Get the major airlines list from IATA (competitor intent, airline type).
Returns airline id, match variants (pipe-separated), and tags.
"""
import os
airlines_path = os.path.join(
os.path.dirname(__file__),
"guardrail_hooks",
"litellm_content_filter",
"competitor_intent",
"major_airlines.json",
)
if not os.path.exists(airlines_path):
raise HTTPException(
status_code=404,
detail="major_airlines.json not found",
)
try:
with open(airlines_path, "r", encoding="utf-8") as f:
import json
airlines = json.load(f)
return {"airlines": airlines}
except Exception as e:
raise HTTPException(
status_code=500, detail=f"Error reading major_airlines.json: {str(e)}"
) from e
@router.post(
"/guardrails/validate_blocked_words_file",
tags=["Guardrails"],
@@ -1,9 +1,8 @@
from typing import TYPE_CHECKING, Optional
import litellm
from litellm.proxy.guardrails.guardrail_hooks.litellm_content_filter.content_filter import (
ContentFilterGuardrail,
)
from litellm.proxy.guardrails.guardrail_hooks.litellm_content_filter.content_filter import \
ContentFilterGuardrail
from litellm.types.guardrails import SupportedGuardrailIntegrations
if TYPE_CHECKING:
@@ -44,6 +43,9 @@ def initialize_guardrail(
severity_threshold=getattr(litellm_params, "severity_threshold", "medium"),
llm_router=llm_router,
image_model=getattr(litellm_params, "image_model", None),
competitor_intent_config=getattr(
litellm_params, "competitor_intent_config", None
),
)
litellm.logging_callback_manager.add_litellm_callback(content_filter_guardrail)
@@ -0,0 +1,17 @@
"""
Competitor intent: entity + intent disambiguation with safe (non-competitor) defaults.
Base logic in base.py; industry-specific checkers in submodules (e.g. airline.py).
"""
from litellm.proxy.guardrails.guardrail_hooks.litellm_content_filter.competitor_intent.airline import \
AirlineCompetitorIntentChecker
from litellm.proxy.guardrails.guardrail_hooks.litellm_content_filter.competitor_intent.base import (
BaseCompetitorIntentChecker, normalize, text_for_entity_matching)
__all__ = [
"BaseCompetitorIntentChecker",
"AirlineCompetitorIntentChecker",
"normalize",
"text_for_entity_matching",
]
@@ -0,0 +1,211 @@
"""
Airline-specific competitor intent: other meaning (e.g. location/travel context) vs competitor airline.
Uses context-based disambiguation only: no hardcoded place lists. Detects travel-location
language (prepositions, travel verbs, booking/entry nouns) vs airline context (airways,
carrier, lounge, miles, etc.) and scores to decide OTHER_MEANING vs COMPETITOR.
When competitors is not provided, loads major_airlines.json and excludes the customer's
brand_self so all other major airlines are treated as competitors.
"""
import json
from pathlib import Path
from typing import Any, Dict, List, Tuple
from litellm.proxy.guardrails.guardrail_hooks.litellm_content_filter.competitor_intent.base import (
BaseCompetitorIntentChecker, _compile_marker, _count_signals,
_word_boundary_match)
# Location/travel context: prepositions, travel verbs, booking nouns, entry/geo nouns.
# No place-name list; these patterns detect "destination context" generically.
AIRLINE_OTHER_MEANING_SIGNALS = [
# Travel verb + preposition (e.g. "fly to", "layover in")
r"\b(fly|flying|travel|traveling|going|visit|visiting|transit|layover|stopover)\b.{0,12}\b(to|from|via|in|at|through|into)\b",
# Booking + preposition
r"\bflight(s)?\b.{0,10}\b(to|from|via)\b",
r"\bticket(s)?\b.{0,8}\b(to|for)\b",
r"\bfare(s)?\b.{0,8}\b(to)\b",
# Entry/geo/booking single words
r"\bvisa\b",
r"\bimmigration\b",
r"\bcustoms\b",
r"\bentry\b",
r"\bairport\b",
r"\bterminal\b",
r"\bgate\b",
r"\bdeparture\b",
r"\barrival\b",
r"\bitinerary\b",
r"\bweather\b",
r"\bhotel\b",
r"\bcity\b",
# Prepositions alone (weaker; often near a place)
r"\bto\s+",
r"\bfrom\s+",
r"\bin\s+",
r"\bat\s+",
r"\bvia\s+",
]
# Airline context: carrier/airline language, cabin, loyalty, operations.
# If ambiguous token appears near these → treat as COMPETITOR.
AIRLINE_COMPETITOR_SIGNALS = [
r"\bairways?\b",
r"\bairline\b",
r"\bcarrier\b",
r"\bcabin\s+crew\b",
r"\bflight\s+attendant\b",
r"\bbusiness\s+class\b",
r"\bfirst\s+class\b",
r"\beconomy\b",
r"\blounge\b",
r"\bbaggage\s+allowance\b",
r"\bcheck[- ]?in\b",
r"\bmiles\b",
r"\bloyalty\b",
r"\bstatus\b",
r"\bfrequent\s+flyer\b",
r"\bfleet\b",
r"\baircraft\b",
# Comparison/ranking
r"\bbetter\b",
r"\bbest\b",
r"\bgood\b",
r"\bas\s+good\s+as\b",
r"\bvs\.?\b",
r"\bversus\b",
r"\bcompare\b",
r"\balternative\b",
r"\bcompetitor\b",
# Brand-specific (optional; config can extend)
r"\bqmiles\b",
r"\bprivilege\s+club\b",
]
# Operational-only: baggage, lounge, check-in, refund (no comparison language).
# When only these appear with ambiguous token → treat as product query (OTHER_MEANING).
AIRLINE_OPERATIONAL_SIGNALS = [
r"\bbaggage\s+allowance\b",
r"\blounge\b",
r"\bcheck[- ]?in\b",
r"\brefund\b",
r"\bpremium\s+lounge\b",
]
# Comparison language: if present with competitor signals → COMPETITOR.
AIRLINE_COMPARISON_SIGNALS = [
r"\bbetter\b",
r"\bbest\b",
r"\bvs\.?\b",
r"\bversus\b",
r"\bcompare\b",
]
# Explicit markers: strong override when present.
AIRLINE_EXPLICIT_COMPETITOR_MARKER = r"\b(airways?|airline|carrier)\b"
AIRLINE_EXPLICIT_OTHER_MEANING_MARKER = (
r"\b(fly|travel|going|visit|layover|stopover|transit)\b.{0,12}\b(to|in|via|from)\b.{0,8}\b"
)
_MAJOR_AIRLINES_PATH = (
Path(__file__).resolve().parent / "major_airlines.json"
)
def _load_competitors_excluding_brand(brand_self: List[str]) -> List[str]:
"""
Load competitor tokens from major_airlines.json (harm_toxic_abuse-style format).
Exclude any airline whose id or match variants overlap with brand_self.
Returns a flat list of match variants (pipe-separated values) from non-excluded airlines.
"""
brand_set = {b.lower().strip() for b in brand_self if b}
if not _MAJOR_AIRLINES_PATH.exists():
return []
try:
with open(_MAJOR_AIRLINES_PATH, encoding="utf-8") as f:
airlines = json.load(f)
except (json.JSONDecodeError, OSError):
return []
result: List[str] = []
for entry in airlines:
if not isinstance(entry, dict):
continue
match_str = entry.get("match") or ""
variants = [v.strip().lower() for v in match_str.split("|") if v.strip()]
words_in_match = set()
for v in variants:
words_in_match.update(v.split())
if brand_set & words_in_match or any(v in brand_set for v in variants):
continue
result.extend(variants)
return result
class AirlineCompetitorIntentChecker(BaseCompetitorIntentChecker):
"""
Disambiguates other meaning (e.g. country/city/airport) vs competitor airline
(e.g. "Qatar" → country vs Qatar Airways). Overrides _classify_ambiguous
with other_meaning/competitor signals and explicit markers.
"""
def __init__(self, config: Dict[str, Any]) -> None:
merged: Dict[str, Any] = dict(config)
if not merged.get("other_meaning_signals"):
merged["other_meaning_signals"] = AIRLINE_OTHER_MEANING_SIGNALS
if not merged.get("competitor_signals"):
merged["competitor_signals"] = AIRLINE_COMPETITOR_SIGNALS
# Optional: no default place list; config can add other_meaning_anchors for extra patterns
if "other_meaning_anchors" not in merged:
merged["other_meaning_anchors"] = []
if not merged.get("explicit_competitor_marker"):
merged["explicit_competitor_marker"] = AIRLINE_EXPLICIT_COMPETITOR_MARKER
if not merged.get("explicit_other_meaning_marker"):
merged["explicit_other_meaning_marker"] = AIRLINE_EXPLICIT_OTHER_MEANING_MARKER
if not merged.get("domain_words"):
merged["domain_words"] = ["airline", "airlines", "carrier"]
if not merged.get("competitors"):
merged["competitors"] = _load_competitors_excluding_brand(
merged.get("brand_self") or []
)
super().__init__(merged)
self._other_meaning_signals = list(merged.get("other_meaning_signals") or [])
self._competitor_signals = list(merged.get("competitor_signals") or [])
self._other_meaning_anchors = list(merged.get("other_meaning_anchors") or [])
self._explicit_competitor_marker = _compile_marker(
merged.get("explicit_competitor_marker")
)
self._explicit_other_meaning_marker = _compile_marker(
merged.get("explicit_other_meaning_marker")
)
def _classify_ambiguous(self, text: str, token: str) -> Tuple[str, float]:
"""Other meaning vs competitor using airline signals and explicit markers."""
text_lower = text.lower()
if self._explicit_competitor_marker and self._explicit_competitor_marker.search(
text_lower
) and _word_boundary_match(text_lower, token.lower()):
return "COMPETITOR", 0.85
if self._explicit_other_meaning_marker and self._explicit_other_meaning_marker.search(
text_lower
):
return "OTHER_MEANING", 0.85
# Operational-only: baggage/lounge/check-in/refund with no comparison → product query
has_comparison = _count_signals(text_lower, AIRLINE_COMPARISON_SIGNALS) > 0
operational_count = _count_signals(text_lower, AIRLINE_OPERATIONAL_SIGNALS)
if not has_comparison and operational_count > 0:
return "OTHER_MEANING", 0.85
# Score: location/travel context vs airline context (no place-name list)
other_count = _count_signals(text_lower, self._other_meaning_signals)
if self._other_meaning_anchors:
other_count += _count_signals(text_lower, self._other_meaning_anchors)
comp_count = _count_signals(text_lower, self._competitor_signals)
total = other_count + comp_count
if total == 0:
return "OTHER_MEANING", 0.5
other_ratio = other_count / total
comp_ratio = comp_count / total
if other_ratio >= 0.6:
return "OTHER_MEANING", min(0.9, 0.5 + 0.4 * other_ratio)
if comp_ratio >= 0.6:
return "COMPETITOR", min(0.9, 0.5 + 0.4 * comp_ratio)
return "OTHER_MEANING", 0.5
@@ -0,0 +1,272 @@
"""
Generic competitor intent checker: two entity sets and overridable disambiguation.
"""
import re
import unicodedata
from typing import Any, Dict, List, Optional, Pattern, Set, Tuple, cast
from litellm.types.proxy.guardrails.guardrail_hooks.litellm_content_filter import (
CompetitorActionHint, CompetitorIntentEvidenceEntry,
CompetitorIntentResult)
ZERO_WIDTH = re.compile(r"[\u200b-\u200d\u2060\ufeff]")
LEET = {"@": "a", "4": "a", "0": "o", "3": "e", "1": "i", "5": "s", "7": "t"}
OTHER_MEANING_DEFAULT_THRESHOLD = (
0.65 # Below this → treat as non-competitor (safe default).
)
def normalize(text: str) -> str:
"""Lowercase, NFKC, strip zero-width, leetspeak, collapse spaces."""
if not text or not isinstance(text, str):
return ""
t = ZERO_WIDTH.sub("", text)
t = unicodedata.normalize("NFKC", t).lower().strip()
for c, r in LEET.items():
t = t.replace(c, r)
return re.sub(r"\s+", " ", t)
def _word_boundary_match(text: str, token: str) -> bool:
"""True if token appears as a word in text."""
return bool(re.search(r"\b" + re.escape(token) + r"\b", text))
def _count_signals(text: str, patterns: List[str]) -> int:
"""Count how many of the patterns appear in text."""
return sum(1 for p in patterns if re.search(p, text, re.IGNORECASE))
def _compile_marker(pattern: Optional[str]) -> Optional[Pattern[str]]:
"""Compile optional regex string to a pattern."""
if not pattern or not pattern.strip():
return None
try:
return re.compile(pattern, re.IGNORECASE)
except re.error:
return None
def text_for_entity_matching(text: str) -> str:
"""Letters-only variant for entity matching (e.g. split punctuation)."""
t = re.sub(r"[^\w\s]", " ", text)
return re.sub(r"\s+", " ", t).strip()
class BaseCompetitorIntentChecker:
"""
Generic competitor intent checker with two entity sets. Ambiguous tokens
(competitor + other-meaning, e.g. location) are classified by overridable
_classify_ambiguous(). Base implementation: treat as non-competitor.
"""
def __init__(self, config: Dict[str, Any]) -> None:
self.brand_self: List[str] = [
s.lower().strip() for s in (config.get("brand_self") or []) if s
]
competitors: List[str] = [
s.lower().strip() for s in (config.get("competitors") or []) if s
]
aliases_map: Dict[str, List[str]] = config.get("competitor_aliases") or {}
self.competitor_canonical: Dict[str, str] = {}
self._competitor_tokens: Set[str] = set()
for c in competitors:
self._competitor_tokens.add(c)
self.competitor_canonical[c] = c
for a in aliases_map.get(c) or []:
a = a.lower().strip()
if a:
self._competitor_tokens.add(a)
self.competitor_canonical[a] = c
other: List[str] = [
s.lower().strip() for s in (config.get("locations") or []) if s
]
self._other_meaning_tokens: Set[str] = set(other)
self._ambiguous: Set[str] = self._competitor_tokens & self._other_meaning_tokens
self.policy: Dict[str, str] = config.get("policy") or {}
self.threshold_high = float(config.get("threshold_high", 0.70))
self.threshold_medium = float(config.get("threshold_medium", 0.45))
self.threshold_low = float(config.get("threshold_low", 0.30))
self.reframe_message_template: Optional[str] = config.get(
"reframe_message_template"
)
self.refuse_message_template: Optional[str] = config.get(
"refuse_message_template"
)
self._comparison_words: List[str] = list(
config.get("comparison_words")
or [
"better",
"worse",
"best",
"vs",
"versus",
"compare",
"alternative",
"recommend",
"ranked",
]
)
self._domain_words: List[str] = [
s.lower().strip() for s in (config.get("domain_words") or []) if s
]
def _classify_ambiguous(self, text: str, token: str) -> Tuple[str, float]:
"""
Override in subclasses for industry-specific logic. Base: treat as non-competitor.
"""
return "OTHER_MEANING", 0.5
def _find_matches(self, text: str) -> List[Tuple[str, str, bool]]:
"""Find competitor matches; mark ambiguous (also in other-meaning set)."""
normalized = normalize(text)
found: List[Tuple[str, str, bool]] = []
seen: Set[Tuple[str, str]] = set()
for token in self._competitor_tokens:
if not _word_boundary_match(normalized, token):
continue
canonical = self.competitor_canonical.get(token, token)
key = (token, canonical)
if key in seen:
continue
seen.add(key)
is_ambig = token in self._ambiguous or token in self._other_meaning_tokens
found.append((token, canonical, is_ambig))
return found
def run(self, text: str) -> CompetitorIntentResult:
"""Classify competitor intent; non-competitor when ambiguous or low confidence."""
normalized = normalize(text)
evidence: List[CompetitorIntentEvidenceEntry] = []
entities: Dict[str, List[str]] = {
"brand_self": [],
"competitors": [],
"category": [],
}
for b in self.brand_self:
if _word_boundary_match(normalized, b):
entities["brand_self"].append(b)
evidence.append(
{"type": "entity", "key": "brand_self", "value": b, "match": b}
)
matches = self._find_matches(text)
if not matches:
has_comparison = any(
re.search(r"\b" + re.escape(w) + r"\b", normalized)
for w in self._comparison_words
)
has_domain = self._domain_words and any(
re.search(r"\b" + re.escape(w) + r"\b", normalized)
for w in self._domain_words
)
if has_comparison and has_domain:
evidence.append(
{
"type": "signal",
"key": "category_ranking",
"match": "comparison + domain",
}
)
action_hint = cast(
CompetitorActionHint,
self.policy.get("category_ranking", "reframe"),
)
return {
"intent": "category_ranking",
"confidence": 0.65,
"entities": entities,
"signals": ["category_ranking"],
"action_hint": action_hint,
"evidence": evidence,
}
return {
"intent": "other",
"confidence": 0.0,
"entities": entities,
"signals": [],
"action_hint": "allow",
"evidence": evidence,
}
competitor_resolved: List[str] = []
for token, canonical, _ in matches:
label, conf = self._classify_ambiguous(normalized, token)
if label == "OTHER_MEANING":
evidence.append(
{"type": "signal", "key": "other_meaning", "match": token}
)
continue
if label == "COMPETITOR":
competitor_resolved.append(canonical)
evidence.append(
{
"type": "entity",
"key": "competitor",
"value": canonical,
"match": token,
}
)
if conf < OTHER_MEANING_DEFAULT_THRESHOLD:
competitor_resolved.pop()
evidence.append(
{
"type": "signal",
"key": "other_meaning_default",
"match": f"confidence {conf:.2f}",
}
)
continue
entities["competitors"] = list(dict.fromkeys(competitor_resolved))
if not competitor_resolved:
return {
"intent": "other",
"confidence": 0.0,
"entities": entities,
"signals": ["other_meaning_or_ambiguous"],
"action_hint": "allow",
"evidence": evidence,
}
has_comparison = any(
re.search(r"\b" + re.escape(w) + r"\b", normalized)
for w in self._comparison_words
)
if has_comparison:
evidence.append(
{"type": "signal", "key": "comparison", "match": "comparison language"}
)
confidence = 0.75 if has_comparison else 0.55
if confidence >= self.threshold_high:
intent = "competitor_comparison"
elif confidence >= self.threshold_medium:
intent = "possible_competitor_comparison"
elif confidence >= self.threshold_low:
intent = "log_only"
else:
intent = "other"
action_hint: CompetitorActionHint = cast(
CompetitorActionHint, self.policy.get(intent, "allow")
)
if intent == "log_only":
action_hint = "log_only"
if intent == "other":
action_hint = "allow"
return {
"intent": intent,
"confidence": round(confidence, 2),
"entities": entities,
"signals": ["competitor_resolved"]
+ (["comparison"] if has_comparison else []),
"action_hint": action_hint,
"evidence": evidence,
}
@@ -10,19 +10,8 @@ import json
import os
import re
from datetime import datetime
from typing import (
TYPE_CHECKING,
Any,
AsyncGenerator,
Dict,
List,
Literal,
Optional,
Pattern,
Tuple,
Union,
cast,
)
from typing import (TYPE_CHECKING, Any, AsyncGenerator, Dict, List, Literal,
Optional, Pattern, Tuple, Union, cast)
import yaml
from fastapi import HTTPException
@@ -31,31 +20,22 @@ from litellm import Router
from litellm._logging import verbose_proxy_logger
from litellm.integrations.custom_guardrail import CustomGuardrail
from litellm.proxy._types import UserAPIKeyAuth
from litellm.types.utils import (
GenericGuardrailAPIInputs,
GuardrailStatus,
GuardrailTracingDetail,
ModelResponseStream,
)
from litellm.types.utils import (GenericGuardrailAPIInputs, GuardrailStatus,
GuardrailTracingDetail, ModelResponseStream)
if TYPE_CHECKING:
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.types.guardrails import (
BlockedWord,
ContentFilterAction,
ContentFilterPattern,
GuardrailEventHooks,
Mode,
)
from litellm.types.guardrails import (BlockedWord, ContentFilterAction,
ContentFilterPattern,
GuardrailEventHooks, Mode)
from litellm.types.proxy.guardrails.guardrail_hooks.litellm_content_filter import (
BlockedWordDetection,
CategoryKeywordDetection,
ContentFilterCategoryConfig,
ContentFilterDetection,
PatternDetection,
)
BlockedWordDetection, CategoryKeywordDetection, CompetitorIntentDetection,
CompetitorIntentResult, ContentFilterCategoryConfig,
ContentFilterDetection, PatternDetection)
from .competitor_intent import (AirlineCompetitorIntentChecker,
BaseCompetitorIntentChecker)
from .patterns import PATTERN_EXTRA_CONFIG, get_compiled_pattern
MAX_KEYWORD_VALUE_GAP_WORDS = 1
@@ -162,6 +142,7 @@ class ContentFilterGuardrail(CustomGuardrail):
severity_threshold: str = "medium",
llm_router: Optional[Router] = None,
image_model: Optional[str] = None,
competitor_intent_config: Optional[Dict[str, Any]] = None,
**kwargs,
):
"""
@@ -212,6 +193,31 @@ class ContentFilterGuardrail(CustomGuardrail):
{}
) # category_name -> {identifier_words, block_words, action, severity}
# Competitor intent checker (optional; airline uses major_airlines.json, generic requires competitors)
self._competitor_intent_checker: Optional[BaseCompetitorIntentChecker] = None
if competitor_intent_config and isinstance(competitor_intent_config, dict):
try:
competitor_intent_type = competitor_intent_config.get(
"competitor_intent_type", "airline"
)
if competitor_intent_type == "generic":
self._competitor_intent_checker = BaseCompetitorIntentChecker(
competitor_intent_config
)
else:
self._competitor_intent_checker = AirlineCompetitorIntentChecker(
competitor_intent_config
)
verbose_proxy_logger.debug(
"ContentFilterGuardrail: competitor intent checker enabled (%s)",
competitor_intent_type,
)
except Exception as e:
verbose_proxy_logger.warning(
"ContentFilterGuardrail: failed to init competitor intent checker: %s",
e,
)
# Load categories if provided
if categories:
self._load_categories(categories)
@@ -1220,9 +1226,7 @@ class ContentFilterGuardrail(CustomGuardrail):
pattern_name=pattern_name.upper()
)
text = self._mask_spans(text, spans, redaction_tag)
verbose_proxy_logger.info(
f"Masked all {pattern_name} matches in content"
)
verbose_proxy_logger.info(f"Masked all {pattern_name} matches in content")
return text
@@ -1453,7 +1457,9 @@ class ContentFilterGuardrail(CustomGuardrail):
masked_entity_count: Dictionary to update with counts
"""
for detection in detections:
if detection["action"] == ContentFilterAction.MASK.value:
if detection.get("type") == "competitor_intent":
continue
if detection.get("action") == ContentFilterAction.MASK.value:
detection_type = detection["type"]
if detection_type == "pattern":
pattern_detection = cast(PatternDetection, detection)
@@ -1479,18 +1485,27 @@ class ContentFilterGuardrail(CustomGuardrail):
"""Build match_details list from content filter detections."""
match_details: List[dict] = []
for detection in detections:
detail: dict = {"type": detection["type"], "action_taken": detection["action"]}
action_taken = detection.get("action", detection.get("action_hint", ""))
detail: dict = {"type": detection["type"], "action_taken": action_taken}
if detection["type"] == "pattern":
detail["detection_method"] = "regex"
detail["snippet"] = cast(PatternDetection, detection).get("pattern_name", "")
detail["snippet"] = cast(PatternDetection, detection).get(
"pattern_name", ""
)
elif detection["type"] == "blocked_word":
detail["detection_method"] = "keyword"
detail["snippet"] = cast(BlockedWordDetection, detection).get("keyword", "")
detail["snippet"] = cast(BlockedWordDetection, detection).get(
"keyword", ""
)
elif detection["type"] == "category_keyword":
detail["detection_method"] = "keyword"
cat_det = cast(CategoryKeywordDetection, detection)
detail["snippet"] = cat_det.get("keyword", "")
detail["category"] = cat_det.get("category", "")
elif detection["type"] == "competitor_intent":
detail["detection_method"] = "intent"
detail["snippet"] = detection.get("intent", "")
detail["confidence"] = detection.get("confidence")
match_details.append(detail)
return match_details
@@ -1500,19 +1515,28 @@ class ContentFilterGuardrail(CustomGuardrail):
for detection in detections:
if detection["type"] == "pattern":
methods.add("regex")
elif detection["type"] == "competitor_intent":
methods.add("intent")
else:
methods.add("keyword")
return ",".join(sorted(methods)) if methods else ""
def _get_patterns_checked_count(self) -> int:
"""Get total number of patterns and keywords that were evaluated."""
return len(self.compiled_patterns) + len(self.blocked_words) + len(self.category_keywords)
return (
len(self.compiled_patterns)
+ len(self.blocked_words)
+ len(self.category_keywords)
)
def _get_policy_templates(self) -> Optional[str]:
"""Get comma-separated policy template names from loaded categories."""
if not self.loaded_categories:
return None
names = [cat.description or cat.category_name for cat in self.loaded_categories.values()]
names = [
cat.description or cat.category_name
for cat in self.loaded_categories.values()
]
return ", ".join(names) if names else None
def _compute_risk_score(
@@ -1550,6 +1574,72 @@ class ContentFilterGuardrail(CustomGuardrail):
return round(min(10.0, score), 1)
def _apply_competitor_intent_policy(
self,
intent_result: CompetitorIntentResult,
request_data: dict,
detections: List[ContentFilterDetection],
) -> None:
"""
Apply policy for competitor intent result: refuse (raise), reframe (passthrough), or log_only/allow (return).
Appends competitor_intent detection to detections. Never returns for refuse/reframe.
"""
intent_val = intent_result.get("intent", "other")
confidence_val = intent_result.get("confidence", 0.0)
action_hint_val = intent_result.get("action_hint", "allow")
evidence_list = intent_result.get("evidence", [])
detection: CompetitorIntentDetection = {
"type": "competitor_intent",
"intent": intent_val,
"confidence": confidence_val,
"action_hint": action_hint_val,
"entities": intent_result.get("entities", {}),
"signals": intent_result.get("signals", []),
"evidence": [dict(e) for e in evidence_list],
}
detections.append(detection)
if action_hint_val == "refuse":
msg = "Content blocked: competitor comparison or ranking intent detected."
if self._competitor_intent_checker and getattr(
self._competitor_intent_checker, "refuse_message_template", None
):
msg = self._competitor_intent_checker.refuse_message_template or msg
verbose_proxy_logger.warning(
"ContentFilterGuardrail: competitor intent refuse - %s", intent_val
)
raise HTTPException(
status_code=403,
detail={
"error": msg,
"intent": intent_val,
"confidence": confidence_val,
},
)
if action_hint_val == "reframe":
msg = (
"I can help with questions about our products and services. "
"Would you like to compare specific features or get more information?"
)
if self._competitor_intent_checker and getattr(
self._competitor_intent_checker, "reframe_message_template", None
):
msg = self._competitor_intent_checker.reframe_message_template or msg
verbose_proxy_logger.info(
"ContentFilterGuardrail: competitor intent reframe - %s", intent_val
)
self.raise_passthrough_exception(
violation_message=msg,
request_data=request_data,
detection_info=dict(intent_result),
)
# log_only or allow: just log (detection already appended)
verbose_proxy_logger.debug(
"ContentFilterGuardrail: competitor intent %s (action_hint=%s)",
intent_val,
action_hint_val,
)
def _log_guardrail_information(
self,
request_data: dict,
@@ -1581,6 +1671,28 @@ class ContentFilterGuardrail(CustomGuardrail):
else [dict(detection) for detection in detections]
)
# Competitor intent: add confidence and classification to tracing if present
tracing_kw: Dict[str, Any] = {
"guardrail_id": self.config_guardrail_id or self.guardrail_name,
"policy_template": self.config_policy_template
or self._get_policy_templates(),
"detection_method": (
self._get_detection_methods(detections) if detections else None
),
"match_details": (
self._build_match_details(detections) if detections else None
),
"patterns_checked": self._get_patterns_checked_count(),
"risk_score": self._compute_risk_score(
detections, masked_entity_count, status
),
}
for d in detections:
if isinstance(d, dict) and d.get("type") == "competitor_intent":
tracing_kw["confidence_score"] = d.get("confidence")
tracing_kw["classification"] = dict(d)
break
self.add_standard_logging_guardrail_information_to_request_data(
guardrail_provider=self.guardrail_provider,
guardrail_json_response=guardrail_json_response,
@@ -1590,14 +1702,7 @@ class ContentFilterGuardrail(CustomGuardrail):
end_time=datetime.now().timestamp(),
duration=(datetime.now() - start_time).total_seconds(),
masked_entity_count=masked_entity_count,
tracing_detail=GuardrailTracingDetail(
guardrail_id=self.config_guardrail_id or self.guardrail_name,
policy_template=self.config_policy_template or self._get_policy_templates(),
detection_method=self._get_detection_methods(detections) if detections else None,
match_details=self._build_match_details(detections) if detections else None,
patterns_checked=self._get_patterns_checked_count(),
risk_score=self._compute_risk_score(detections, masked_entity_count, status),
),
tracing_detail=GuardrailTracingDetail(**tracing_kw),
)
async def apply_guardrail(
@@ -1645,6 +1750,13 @@ class ContentFilterGuardrail(CustomGuardrail):
processed_texts = []
for text in texts:
# Competitor intent check first (optional; may refuse/reframe)
if self._competitor_intent_checker and text:
intent_result = self._competitor_intent_checker.run(text)
if intent_result.get("intent", "other") != "other":
self._apply_competitor_intent_policy(
intent_result, request_data, detections
)
filtered_text = self._filter_single_text(text, detections=detections)
processed_texts.append(filtered_text)
@@ -1766,8 +1878,7 @@ class ContentFilterGuardrail(CustomGuardrail):
@staticmethod
def get_config_model():
from litellm.types.proxy.guardrails.guardrail_hooks.litellm_content_filter import (
LitellmContentFilterGuardrailConfigModel,
)
from litellm.types.proxy.guardrails.guardrail_hooks.litellm_content_filter import \
LitellmContentFilterGuardrailConfigModel
return LitellmContentFilterGuardrailConfigModel
return LitellmContentFilterGuardrailConfigModel
@@ -0,0 +1,58 @@
# Content Filter Examples
## Industry-specific competitor intent (airline)
Use **competitor_intent_type: airline** for simplified config; competitors are auto-loaded from IATA `major_airlines.json`, excluding your `brand_self`. Use **competitor_intent_type: generic** when you want to specify competitors manually.
- **brand_self**: Your brand names and aliases (e.g. `["your-brand", "your-code"]`)
- **competitors** (generic only): List of competitor names
To make it effective for a specific industry (e.g. airlines), add an **industry layer** on top:
1. **domain_words** Terms that signal “this is about our vertical.”
For airline: `airline`, `carrier`, `flight`, `business class`, `lounge`, etc.
This enables the **category_ranking** path (e.g. “Which Gulf airline is the best?”) and the scoring **gate** (so “best” alone doesnt trigger without domain/geo).
2. **route_geo_cues** Optional geography/hub terms.
For airline: `country`, `hub-city`, `airport-code`, `region`.
3. **descriptor_lexicon** Phrases that count as indirect competitor reference.
For aviation: `gulf carrier`, `five star airline`, etc.
4. **competitor_aliases** Per-competitor aliases (IATA codes, nicknames).
Example: `competitor-name``["iata-code", "nickname"]`.
5. **policy** What to do per intent band: `refuse`, `reframe`, `log_only`, or `allow`.
Example: `competitor_comparison: refuse`, `category_ranking: reframe`.
See the config examples below for how to add this to your proxy `guardrails` config.
### Using the example in your proxy config
In `config.yaml`:
```yaml
guardrails:
- guardrail_name: "airline-competitor-intent"
litellm_params:
guardrail: litellm_content_filter
mode: pre_call
competitor_intent_config:
competitor_intent_type: airline
brand_self: [your-brand, your-code]
locations: [relevant-country, hub-city]
policy:
competitor_comparison: refuse
possible_competitor_comparison: reframe
```
Then attach this guardrail to your router/policy (e.g. `guardrails.add: [airline-competitor-intent]`).
### Other industries
Use the same pattern:
- **SaaS**: `domain_words`: `["platform", "tool", "solution", "integration"]`; optional `route_geo_cues` if regional.
- **Retail**: `domain_words`: `["store", "brand", "product line"]`; `competitor_aliases` for brand nicknames.
The implementation is generic; only the config (and optional industry presets) are industry-specific.
@@ -12,14 +12,7 @@ All /policy management endpoints
import copy
import json
import os
from typing import (
TYPE_CHECKING,
AsyncIterator,
List,
Literal,
Optional,
cast,
)
from typing import TYPE_CHECKING, AsyncIterator, List, Literal, Optional, cast
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import StreamingResponse
@@ -27,11 +20,9 @@ from pydantic import BaseModel, Field
from typing_extensions import TypedDict
from litellm._logging import verbose_proxy_logger
from litellm.constants import (
COMPETITOR_LLM_TEMPERATURE,
DEFAULT_COMPETITOR_DISCOVERY_MODEL,
MAX_COMPETITOR_NAMES,
)
from litellm.constants import (COMPETITOR_LLM_TEMPERATURE,
DEFAULT_COMPETITOR_DISCOVERY_MODEL,
MAX_COMPETITOR_NAMES)
from litellm.integrations.custom_guardrail import CustomGuardrail
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
@@ -39,21 +30,20 @@ from litellm.proxy.guardrails.guardrail_registry import GuardrailRegistry
from litellm.proxy.management_helpers.utils import management_endpoint_wrapper
from litellm.proxy.policy_engine.policy_registry import get_policy_registry
from litellm.proxy.policy_engine.policy_resolver import PolicyResolver
from litellm.types.proxy.policy_engine import (
PolicyGuardrailsResponse,
PolicyInfoResponse,
PolicyListResponse,
PolicyMatchContext,
PolicyScopeResponse,
PolicySummaryItem,
PolicyTestResponse,
PolicyValidateRequest,
PolicyValidationResponse,
)
from litellm.types.proxy.policy_engine import (PolicyGuardrailsResponse,
PolicyInfoResponse,
PolicyListResponse,
PolicyMatchContext,
PolicyScopeResponse,
PolicySummaryItem,
PolicyTestResponse,
PolicyValidateRequest,
PolicyValidationResponse)
from litellm.types.utils import GenericGuardrailAPIInputs
if TYPE_CHECKING:
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.litellm_core_utils.litellm_logging import \
Logging as LiteLLMLoggingObj
router = APIRouter()
@@ -86,6 +76,19 @@ class ApplyPoliciesResult(TypedDict):
guardrail_errors: List[GuardrailErrorEntry]
class ApplyPoliciesPerItemResult(TypedDict):
"""Result for one input when using inputs_list."""
inputs: GenericGuardrailAPIInputs
guardrail_errors: List[GuardrailErrorEntry]
class ApplyPoliciesListResult(TypedDict):
"""Result when using inputs_list: one result per input."""
results: List[ApplyPoliciesPerItemResult]
async def apply_policies(
policy_names: Optional[list[str]],
inputs: GenericGuardrailAPIInputs,
@@ -187,7 +190,14 @@ class TestPoliciesAndGuardrailsRequest(BaseModel):
policy_names: Optional[List[str]] = Field(default=None, description="Policy names to resolve guardrails from")
guardrail_names: Optional[List[str]] = Field(default=None, description="Guardrail names to apply directly")
inputs: dict = Field(description="GenericGuardrailAPIInputs, e.g. { \"texts\": [\"...\"] }")
inputs: Optional[dict] = Field(
default=None,
description="GenericGuardrailAPIInputs, e.g. { \"texts\": [\"...\"] }. Use inputs_list for per-input processing.",
)
inputs_list: Optional[List[dict]] = Field(
default=None,
description="List of GenericGuardrailAPIInputs; each item processed separately (for batch compliance testing).",
)
request_data: dict = Field(default_factory=dict, description="Request context (model, user_id, etc.)")
input_type: Literal["request", "response"] = Field(default="request", description="Whether inputs are request or response")
@@ -206,26 +216,48 @@ async def test_policies_and_guardrails(
"""
Apply policies and/or guardrails to inputs (for compliance UI testing).
Runs all guardrails in order; failures are collected and returned in guardrail_errors.
Returns inputs (possibly modified) and any guardrail errors so the UI can show which
guardrails failed and why.
Use inputs_list for batch testing: each input is processed as a separate call so
per-input block/allow and errors are returned.
Use inputs for a single call (legacy).
"""
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.litellm_core_utils.litellm_logging import \
Logging as LiteLLMLoggingObj
from litellm.proxy.proxy_server import proxy_logging_obj
from litellm.proxy.utils import handle_exception_on_proxy
try:
inputs_typed = cast(GenericGuardrailAPIInputs, data.inputs)
logging_obj = cast(LiteLLMLoggingObj, proxy_logging_obj)
result = await apply_policies(
policy_names=data.policy_names,
inputs=inputs_typed,
request_data=data.request_data,
input_type=data.input_type,
proxy_logging_obj=logging_obj,
guardrail_names=data.guardrail_names,
)
return result
if data.inputs_list is not None:
results: List[ApplyPoliciesPerItemResult] = []
for inp in data.inputs_list:
inputs_typed = cast(GenericGuardrailAPIInputs, inp)
item_result = await apply_policies(
policy_names=data.policy_names,
inputs=inputs_typed,
request_data=data.request_data,
input_type=data.input_type,
proxy_logging_obj=logging_obj,
guardrail_names=data.guardrail_names,
)
results.append(
ApplyPoliciesPerItemResult(
inputs=item_result["inputs"],
guardrail_errors=item_result["guardrail_errors"],
)
)
return ApplyPoliciesListResult(results=results)
if data.inputs is not None:
inputs_typed = cast(GenericGuardrailAPIInputs, data.inputs)
return await apply_policies(
policy_names=data.policy_names,
inputs=inputs_typed,
request_data=data.request_data,
input_type=data.input_type,
proxy_logging_obj=logging_obj,
guardrail_names=data.guardrail_names,
)
raise ValueError("Either inputs or inputs_list must be provided")
except Exception as e:
raise handle_exception_on_proxy(e)
@@ -506,7 +538,8 @@ async def get_policy_templates(
return _load_policy_templates_from_local_backup()
try:
from litellm.llms.custom_httpx.http_handler import get_async_httpx_client
from litellm.llms.custom_httpx.http_handler import \
get_async_httpx_client
from litellm.types.llms.custom_http import httpxSpecialProvider
async_client = get_async_httpx_client(
@@ -981,9 +1014,8 @@ async def suggest_policy_templates(
Calls an LLM with tool calling to match user requirements to available templates.
"""
from litellm.proxy.management_endpoints.policy_endpoints.ai_policy_suggester import (
AiPolicySuggester,
)
from litellm.proxy.management_endpoints.policy_endpoints.ai_policy_suggester import \
AiPolicySuggester
templates = _load_policy_templates_from_local_backup()
suggester = AiPolicySuggester()
@@ -1053,9 +1085,8 @@ async def _test_guardrail_definitions(
text: str,
) -> List[GuardrailTestResultEntry]:
"""Instantiate and run each guardrail definition against the text."""
from litellm.proxy.guardrails.guardrail_hooks.litellm_content_filter.content_filter import (
ContentFilterGuardrail,
)
from litellm.proxy.guardrails.guardrail_hooks.litellm_content_filter.content_filter import \
ContentFilterGuardrail
results: List[GuardrailTestResultEntry] = []
@@ -1,10 +1,42 @@
from enum import Enum
from typing import List, Literal, Optional, TypedDict, Union
from typing import Any, Dict, List, Literal, Optional, TypedDict, Union
from pydantic import Field
from litellm.types.llms.base import BaseLiteLLMOpenAIResponseObject
from litellm.types.proxy.guardrails.guardrail_hooks.base import GuardrailConfigModel
from litellm.types.proxy.guardrails.guardrail_hooks.base import \
GuardrailConfigModel
# --- Competitor intent blocker (generic, industry-agnostic) ---
CompetitorIntentType = Literal[
"competitor_comparison",
"possible_competitor_comparison",
"category_ranking",
"log_only",
"other",
]
CompetitorActionHint = Literal["allow", "reframe", "refuse", "escalate", "log_only"]
class CompetitorIntentEvidenceEntry(TypedDict, total=False):
"""Single evidence entry: what matched and what it resolved to."""
type: Literal["entity", "signal"]
key: str # e.g. "competitor", "ranking", "brand_self"
value: Optional[str] # resolved canonical value (e.g. "qatar_airways")
match: str # matched substring
class CompetitorIntentResult(TypedDict, total=False):
"""Structured output from competitor intent checker."""
intent: CompetitorIntentType
confidence: float
entities: Dict[str, List[str]] # brand_self, competitors, category
signals: List[str]
action_hint: CompetitorActionHint
evidence: List[CompetitorIntentEvidenceEntry]
# Detection type enum
@@ -37,7 +69,24 @@ class CategoryKeywordDetection(TypedDict):
action: str # ContentFilterAction.value
ContentFilterDetection = Union[PatternDetection, BlockedWordDetection, CategoryKeywordDetection]
class CompetitorIntentDetection(TypedDict):
"""Detection from competitor intent checker (intent + evidence)."""
type: Literal["competitor_intent"]
intent: str
confidence: float
action_hint: str
entities: Dict[str, List[str]]
signals: List[str]
evidence: List[Dict[str, Any]]
ContentFilterDetection = Union[
PatternDetection,
BlockedWordDetection,
CategoryKeywordDetection,
CompetitorIntentDetection,
]
class ContentFilterCategoryConfig(BaseLiteLLMOpenAIResponseObject):
@@ -113,6 +162,17 @@ class LitellmContentFilterGuardrailConfigModel(GuardrailConfigModel):
description="Tag to use for keyword redaction",
)
# Competitor intent blocker (generic; industry presets add domain_words, etc.)
competitor_intent_config: Optional[Dict[str, Any]] = Field(
default=None,
description="Optional config for intent-based competitor comparison detection. "
"Keys: brand_self (list), competitors (list), competitor_aliases (dict), "
"domain_words (list, optional), route_geo_cues (list, optional), "
"descriptor_lexicon (list, optional), indirect_competitor_patterns (dict, optional), "
"policy (dict), threshold_high, threshold_medium, threshold_low, "
"reframe_message_template, refuse_message_template.",
)
@staticmethod
def ui_friendly_name() -> str:
return "LiteLLM Content Filter"
@@ -0,0 +1,314 @@
"""
Tests for competitor intent detection (normalize, entity layer, scoring, policy).
"""
import pytest
from litellm.proxy.guardrails.guardrail_hooks.litellm_content_filter.competitor_intent import (
AirlineCompetitorIntentChecker, normalize, text_for_entity_matching)
class TestNormalize:
"""Test text normalization (leetspeak, spacing, zero-width)."""
def test_normalize_lowercase(self):
assert normalize("Is Qatar Better?") == "is qatar better?"
def test_normalize_leetspeak(self):
assert "qatar" in normalize("q@tar")
assert "qatar" in normalize("q4tar")
def test_normalize_collapse_whitespace(self):
assert normalize("hello world") == "hello world"
def test_normalize_spaced_out_letters(self):
# Single-letter tokens collapsed into word
assert "qatar" in normalize("q a t a r").replace(" ", "")
def test_normalize_empty(self):
assert normalize("") == ""
assert normalize(None) == ""
def test_text_for_entity_matching_removes_punctuation(self):
t = text_for_entity_matching("q.a.t.a.r emirates")
assert "emirates" in t
assert "." not in t
class TestAirlineCompetitorIntentChecker:
"""Test AirlineCompetitorIntentChecker run() and intent bands."""
@pytest.fixture
def generic_config(self):
return {
"brand_self": ["emirates", "ek"],
"competitors": ["qatar airways", "etihad", "qatar"],
"competitor_aliases": {
"qatar airways": ["qr", "doha airline"],
"qatar": ["qr"],
},
"locations": ["qatar", "doha", "doh"],
"domain_words": ["airline", "carrier", "flight", "business class"],
"route_geo_cues": ["doha", "dubai", "abu dhabi"],
"policy": {
"competitor_comparison": "refuse",
"possible_competitor_comparison": "reframe",
"category_ranking": "reframe",
"log_only": "log_only",
},
"threshold_high": 0.70,
"threshold_medium": 0.45,
"threshold_low": 0.30,
}
def test_run_other_intent(self, generic_config):
checker = AirlineCompetitorIntentChecker(generic_config)
result = checker.run("What is the weather today?")
assert result["intent"] == "other"
assert result["action_hint"] == "allow"
def test_run_competitor_comparison_direct(self, generic_config):
checker = AirlineCompetitorIntentChecker(generic_config)
result = checker.run("Is Qatar better than Emirates?")
assert result["intent"] in ("competitor_comparison", "possible_competitor_comparison")
assert "competitor_entity" in result.get("signals", []) or "competitors" in str(result.get("entities", {}))
assert result["confidence"] >= 0.45
def test_run_competitor_comparison_as_good_as(self, generic_config):
checker = AirlineCompetitorIntentChecker(generic_config)
result = checker.run("Is Qatar as good as Emirates?")
assert result["intent"] != "other"
assert result["confidence"] >= 0.45
def test_run_ranking_with_competitor(self, generic_config):
checker = AirlineCompetitorIntentChecker(generic_config)
result = checker.run("Why is Qatar Airways the best?")
assert result["intent"] != "other"
assert "qatar" in str(result.get("entities", {}).get("competitors", [])).lower() or "competitor" in str(result.get("signals", []))
def test_run_ranking_without_competitor_category_ranking(self, generic_config):
checker = AirlineCompetitorIntentChecker(generic_config)
result = checker.run("Which Gulf airline is the best?")
# domain_words "airline" + ranking "best" + geo "gulf" not in route_geo_cues but "airline" is domain
assert result["intent"] in ("category_ranking", "possible_competitor_comparison", "log_only", "other")
def test_run_evidence_populated(self, generic_config):
checker = AirlineCompetitorIntentChecker(generic_config)
result = checker.run("Is Qatar better than Emirates?")
assert "evidence" in result
assert isinstance(result["evidence"], list)
def test_run_gate_prevents_false_positive(self, generic_config):
# "best" alone without entity or domain should not trigger competitor_comparison
checker = AirlineCompetitorIntentChecker(generic_config)
result = checker.run("What is the best way to cook pasta?")
assert result["intent"] in ("other", "log_only")
def test_other_meaning_context_suppression(self, generic_config):
# "flights to qatar" = other meaning (country), not competitor airline
checker = AirlineCompetitorIntentChecker(generic_config)
result = checker.run("how expensive are flights to qatar?")
assert result["intent"] == "other"
assert not result.get("entities", {}).get("competitors")
class TestContentFilterWithCompetitorIntent:
"""Integration: ContentFilterGuardrail with competitor_intent_config."""
@pytest.mark.asyncio
async def test_competitor_intent_type_airline_uses_airline_checker(self):
"""When competitor_intent_type is airline (default), use AirlineCompetitorIntentChecker."""
from litellm.proxy.guardrails.guardrail_hooks.litellm_content_filter.content_filter import \
ContentFilterGuardrail
guardrail = ContentFilterGuardrail(
guardrail_name="test-airline",
competitor_intent_config={
"competitor_intent_type": "airline",
"brand_self": ["emirates", "ek"],
"locations": ["qatar", "doha"],
"policy": {"competitor_comparison": "refuse"},
},
)
assert guardrail._competitor_intent_checker is not None
from litellm.proxy.guardrails.guardrail_hooks.litellm_content_filter.competitor_intent import \
AirlineCompetitorIntentChecker
assert isinstance(guardrail._competitor_intent_checker, AirlineCompetitorIntentChecker)
@pytest.mark.asyncio
async def test_competitor_intent_type_generic_uses_base_checker(self):
"""When competitor_intent_type is generic, use BaseCompetitorIntentChecker."""
from litellm.proxy.guardrails.guardrail_hooks.litellm_content_filter.competitor_intent import \
BaseCompetitorIntentChecker
from litellm.proxy.guardrails.guardrail_hooks.litellm_content_filter.content_filter import \
ContentFilterGuardrail
guardrail = ContentFilterGuardrail(
guardrail_name="test-generic",
competitor_intent_config={
"competitor_intent_type": "generic",
"brand_self": ["acme"],
"competitors": ["widget inc", "gadget corp"],
"policy": {"competitor_comparison": "refuse"},
},
)
assert guardrail._competitor_intent_checker is not None
assert isinstance(guardrail._competitor_intent_checker, BaseCompetitorIntentChecker)
@pytest.mark.asyncio
async def test_apply_guardrail_with_competitor_intent_allow(self):
from litellm.proxy.guardrails.guardrail_hooks.litellm_content_filter.content_filter import \
ContentFilterGuardrail
guardrail = ContentFilterGuardrail(
guardrail_name="test-competitor",
competitor_intent_config={
"brand_self": ["emirates"],
"competitors": ["qatar"],
"domain_words": ["airline"],
"policy": {"competitor_comparison": "refuse", "possible_competitor_comparison": "reframe"},
},
)
inputs = {"texts": ["What is the capital of France?"]}
result = await guardrail.apply_guardrail(
inputs, request_data={}, input_type="request"
)
assert result["texts"] == ["What is the capital of France?"]
@pytest.mark.asyncio
async def test_apply_guardrail_with_competitor_intent_refuse(self):
from fastapi import HTTPException
from litellm.proxy.guardrails.guardrail_hooks.litellm_content_filter.content_filter import \
ContentFilterGuardrail
guardrail = ContentFilterGuardrail(
guardrail_name="test-competitor",
competitor_intent_config={
"brand_self": ["emirates"],
"competitors": ["qatar airways"],
"domain_words": ["airline", "flight"],
"policy": {"competitor_comparison": "refuse"},
"threshold_high": 0.5,
},
)
inputs = {"texts": ["Is Qatar Airways better than Emirates?"]}
with pytest.raises(HTTPException) as exc_info:
await guardrail.apply_guardrail(
inputs, request_data={}, input_type="request"
)
assert exc_info.value.status_code == 403
# Exact config from litellm/proxy/_new_secret_config.yaml (lines 27-53).
AIRLINE_PROXY_CONFIG = {
"brand_self": ["emirates", "ek"],
"competitors": ["qatar airways", "qatar", "etihad"],
"locations": ["qatar", "doha", "doh"],
"competitor_aliases": {
"qatar airways": ["qr", "doha airline"],
"qatar": ["qr"],
},
"policy": {
"competitor_comparison": "refuse",
"possible_competitor_comparison": "reframe",
},
"threshold_high": 0.70,
"threshold_medium": 0.45,
"threshold_low": 0.30,
}
# Config matching proxy + domain_words, category_ranking, and competitors for full dataset.
AIRLINE_COMPLIANCE_CONFIG = {
"brand_self": ["emirates", "ek"],
"competitors": [
"qatar airways",
"qatar",
"etihad",
"turkish airlines",
"lufthansa",
"singapore airlines",
],
"locations": ["qatar", "doha", "doh"],
"competitor_aliases": {
"qatar airways": ["qr", "doha airline"],
"qatar": ["qr"],
},
"domain_words": ["airline", "airlines", "carrier"],
"policy": {
"competitor_comparison": "refuse",
"possible_competitor_comparison": "reframe",
"category_ranking": "reframe",
},
"threshold_high": 0.70,
"threshold_medium": 0.45,
"threshold_low": 0.30,
}
# Dataset from ui/litellm-dashboard/src/data/compliancePrompts.ts airlineCompliancePrompts
AIRLINE_COMPLIANCE_DATASET = [
# Destination Intent (should PASS)
("airline_001", "Do you have flights to Qatar?", "pass"),
("airline_002", "How long is the flight to Doha?", "pass"),
("airline_003", "Do I need a visa to transit through Qatar?", "pass"),
("airline_004", "Are there lounges at Hamad Airport?", "pass"),
("airline_005", "What documents are required to enter Singapore?", "pass"),
("airline_006", "What airlines fly to Turkey?", "pass"),
("airline_007", "What is the airport code for Doha?", "pass"),
("airline_008", "Is Doha a good place for a long layover?", "pass"),
("airline_009", "What is the weather like in Qatar in March?", "pass"),
("airline_010", "How much connection time do I need in Doha?", "pass"),
# Competitor Comparison (should FAIL)
("airline_011", "Is Qatar Airways better than Emirates?", "fail"),
("airline_012", "Which airline has better business class, Qatar or Singapore Airlines?", "fail"),
("airline_013", "Which airline is the best for long haul flights?", "fail"),
("airline_014", "Should I choose Qatar Airways or another airline?", "fail"),
("airline_015", "Compare Turkish Airlines vs Lufthansa.", "fail"),
("airline_016", "Which airline has better lounges?", "fail"),
("airline_017", "Which airline has the best customer satisfaction?", "fail"),
("airline_018", "Is the Doha airline better than other carriers?", "fail"),
("airline_019", "Should I switch to another airline for a better experience?", "fail"),
("airline_020", "Which airline is ranked number one worldwide?", "fail"),
# Ambiguous Entity (should PASS)
("airline_021", "Qatar baggage allowance", "pass"),
("airline_022", "Qatar lounge access rules", "pass"),
("airline_023", "Qatar check in time", "pass"),
("airline_024", "Doha premium lounge access", "pass"),
("airline_025", "Qatar refund policy", "pass"),
]
class TestAirlineComplianceDataset:
"""Run full airline compliance dataset with proxy config; all cases must match expected outcome."""
def test_airline_001_passes_with_exact_proxy_config(self):
"""With exact proxy config, first compliance case (flights to Qatar) must pass (allow)."""
checker = AirlineCompetitorIntentChecker(AIRLINE_PROXY_CONFIG)
result = checker.run("Do you have flights to Qatar?")
assert result["intent"] == "other"
assert result["action_hint"] == "allow"
def test_airline_compliance_dataset_with_proxy_config(self):
"""Every prompt must get intent/action consistent with expectedResult (pass=allow, fail=refuse/reframe)."""
checker = AirlineCompetitorIntentChecker(AIRLINE_COMPLIANCE_CONFIG)
failures = []
for prompt_id, prompt_text, expected in AIRLINE_COMPLIANCE_DATASET:
result = checker.run(prompt_text)
intent = result.get("intent", "other")
action_hint = result.get("action_hint", "allow")
if expected == "pass":
allowed = intent == "other" and action_hint == "allow"
if not allowed:
failures.append(
f"{prompt_id}: expected pass, got intent={intent!r} action_hint={action_hint!r} for {prompt_text!r}"
)
else:
blocked = (
intent != "other"
and action_hint in ("refuse", "reframe")
)
if not blocked:
failures.append(
f"{prompt_id}: expected fail, got intent={intent!r} action_hint={action_hint!r} for {prompt_text!r}"
)
assert not failures, f"Airline compliance dataset failures:\n" + "\n".join(failures)
@@ -110,6 +110,8 @@ const AddGuardrailForm: React.FC<AddGuardrailFormProps> = ({ visible, onClose, a
const [blockedWords, setBlockedWords] = useState<any[]>([]);
const [selectedContentCategories, setSelectedContentCategories] = useState<any[]>([]);
const [pendingCategorySelection, setPendingCategorySelection] = useState<string>("");
const [competitorIntentEnabled, setCompetitorIntentEnabled] = useState(false);
const [competitorIntentConfig, setCompetitorIntentConfig] = useState<any>(null);
const [toolPermissionConfig, setToolPermissionConfig] = useState<ToolPermissionConfig>({
rules: [],
default_action: "deny",
@@ -175,6 +177,8 @@ const AddGuardrailForm: React.FC<AddGuardrailFormProps> = ({ visible, onClose, a
setBlockedWords([]);
setSelectedContentCategories([]);
setPendingCategorySelection("");
setCompetitorIntentEnabled(false);
setCompetitorIntentConfig(null);
setToolPermissionConfig({
rules: [],
@@ -254,7 +258,13 @@ const AddGuardrailForm: React.FC<AddGuardrailFormProps> = ({ visible, onClose, a
setCurrentStep(currentStep - 1);
};
const handleAddAndContinue = () => {
const handleAddAndContinue = (competitorIntentOnly?: boolean) => {
// Competitor intent only: just advance to next step (no category to add)
if (competitorIntentOnly) {
setCurrentStep(currentStep + 1);
return;
}
if (!pendingCategorySelection || !guardrailSettings) return;
const contentFilterSettings = guardrailSettings.content_filter_settings;
@@ -363,12 +373,20 @@ const AddGuardrailForm: React.FC<AddGuardrailFormProps> = ({ visible, onClose, a
}
}
// For Content Filter, add patterns, blocked words, and categories
// For Content Filter, add patterns, blocked words, categories, and optionally competitor intent
if (shouldRenderContentFilterConfigSettings(values.provider)) {
// Validate that at least one content filter setting is configured
if (selectedPatterns.length === 0 && blockedWords.length === 0 && selectedContentCategories.length === 0) {
const hasCompetitorIntent =
competitorIntentEnabled &&
competitorIntentConfig?.brand_self?.length > 0;
if (
selectedPatterns.length === 0 &&
blockedWords.length === 0 &&
selectedContentCategories.length === 0 &&
!hasCompetitorIntent
) {
NotificationsManager.fromBackend(
"Please configure at least one setting (denied topic, pattern, or word filter)"
"Please configure at least one content filter setting (category, pattern, keyword, or competitor intent)"
);
setLoading(false);
return;
@@ -398,6 +416,25 @@ const AddGuardrailForm: React.FC<AddGuardrailFormProps> = ({ visible, onClose, a
severity_threshold: c.severity_threshold || "medium",
}));
}
if (competitorIntentEnabled && competitorIntentConfig?.brand_self?.length > 0) {
guardrailData.litellm_params.competitor_intent_config = {
competitor_intent_type: competitorIntentConfig.competitor_intent_type ?? "airline",
brand_self: competitorIntentConfig.brand_self,
locations:
competitorIntentConfig.locations?.length > 0
? competitorIntentConfig.locations
: undefined,
competitors:
competitorIntentConfig.competitor_intent_type === "generic" &&
competitorIntentConfig.competitors?.length > 0
? competitorIntentConfig.competitors
: undefined,
policy: competitorIntentConfig.policy,
threshold_high: competitorIntentConfig.threshold_high,
threshold_medium: competitorIntentConfig.threshold_medium,
threshold_low: competitorIntentConfig.threshold_low,
};
}
}
// Add config values to the guardrail_info if provided
else if (values.config) {
@@ -712,6 +749,12 @@ const AddGuardrailForm: React.FC<AddGuardrailFormProps> = ({ visible, onClose, a
onPendingCategorySelectionChange={setPendingCategorySelection}
accessToken={accessToken}
showStep={step}
competitorIntentEnabled={competitorIntentEnabled}
competitorIntentConfig={competitorIntentConfig}
onCompetitorIntentChange={(enabled, config) => {
setCompetitorIntentEnabled(enabled);
setCompetitorIntentConfig(config);
}}
/>
);
};
@@ -769,28 +812,52 @@ const AddGuardrailForm: React.FC<AddGuardrailFormProps> = ({ visible, onClose, a
}
};
const getStepConfigs = () => {
const isContentFilter = shouldRenderContentFilterConfigSettings(selectedProvider);
const isPII = shouldRenderPIIConfigSettings(selectedProvider);
const renderStepButtons = () => {
const totalSteps = shouldRenderContentFilterConfigSettings(selectedProvider) ? 4 : 2;
const isLastStep = currentStep === totalSteps - 1;
const isCategoriesStep = shouldRenderContentFilterConfigSettings(selectedProvider) && currentStep === 1;
const hasPendingCategory = pendingCategorySelection !== "";
const hasCompetitorIntentConfigured =
competitorIntentEnabled && (competitorIntentConfig?.brand_self?.length ?? 0) > 0;
const canContinueFromCategoriesStep = hasPendingCategory || hasCompetitorIntentConfigured;
const steps = [
{ title: "Guardrail details", optional: false },
{
title: isPII
? "PII Configuration"
: isContentFilter
? "Denied topics"
: "Provider Configuration",
optional: true,
},
];
if (isContentFilter) {
steps.push({ title: "Patterns", optional: true });
steps.push({ title: "Word filters", optional: true });
}
return steps;
return (
<div className="flex justify-end space-x-2 mt-4">
{currentStep > 0 && (
<Button onClick={prevStep}>
Previous
</Button>
)}
{isCategoriesStep ? (
<>
<Button onClick={nextStep}>
Skip
</Button>
<Button
type="primary"
onClick={() => handleAddAndContinue(hasCompetitorIntentConfigured)}
disabled={!canContinueFromCategoriesStep}
>
{hasPendingCategory ? "Add & Continue →" : "Continue →"}
</Button>
</>
) : (
<>
{!isLastStep && (
<Button type="primary" onClick={nextStep}>Next</Button>
)}
{isLastStep && (
<Button type="primary" onClick={handleSubmit} loading={loading}>
Create Guardrail
</Button>
)}
</>
)}
<Button onClick={handleClose}>
Cancel
</Button>
</div>
);
};
const stepConfigs = getStepConfigs();
@@ -0,0 +1,335 @@
import React, { useEffect, useState } from "react";
import {
Card,
Typography,
Select,
Switch,
Form,
Space,
InputNumber,
} from "antd";
import { getMajorAirlines } from "../../networking";
const { Title, Text } = Typography;
const { Option } = Select;
export interface MajorAirline {
id: string;
match: string;
tags: string[];
}
export interface CompetitorIntentConfig {
competitor_intent_type: "airline" | "generic";
brand_self: string[];
locations?: string[];
competitors?: string[];
policy?: {
competitor_comparison?: "refuse" | "reframe";
possible_competitor_comparison?: "refuse" | "reframe";
};
threshold_high?: number;
threshold_medium?: number;
threshold_low?: number;
}
interface CompetitorIntentConfigurationProps {
enabled: boolean;
config: CompetitorIntentConfig | null;
onChange: (enabled: boolean, config: CompetitorIntentConfig | null) => void;
accessToken?: string | null;
}
const DEFAULT_CONFIG: CompetitorIntentConfig = {
competitor_intent_type: "airline",
brand_self: [],
locations: [],
policy: {
competitor_comparison: "refuse",
possible_competitor_comparison: "reframe",
},
threshold_high: 0.7,
threshold_medium: 0.45,
threshold_low: 0.3,
};
const CompetitorIntentConfiguration: React.FC<
CompetitorIntentConfigurationProps
> = ({ enabled, config, onChange, accessToken }) => {
const effectiveConfig = config ?? DEFAULT_CONFIG;
const [airlineOptions, setAirlineOptions] = useState<MajorAirline[]>([]);
const [loadingAirlines, setLoadingAirlines] = useState(false);
useEffect(() => {
if (
effectiveConfig.competitor_intent_type === "airline" &&
accessToken &&
airlineOptions.length === 0
) {
setLoadingAirlines(true);
getMajorAirlines(accessToken)
.then((res) => setAirlineOptions(res.airlines ?? []))
.catch(() => setAirlineOptions([]))
.finally(() => setLoadingAirlines(false));
}
}, [effectiveConfig.competitor_intent_type, accessToken, airlineOptions.length]);
const handleEnabledChange = (checked: boolean) => {
onChange(checked, checked ? { ...DEFAULT_CONFIG } : null);
};
const handleConfigChange = (field: string, value: unknown) => {
onChange(enabled, { ...effectiveConfig, [field]: value });
};
const handlePolicyChange = (key: string, value: string) => {
onChange(enabled, {
...effectiveConfig,
policy: { ...effectiveConfig.policy, [key]: value },
});
};
const handleNestedArrayChange = (field: "brand_self" | "locations" | "competitors", values: string[]) => {
onChange(enabled, { ...effectiveConfig, [field]: values.filter(Boolean) });
};
const handleBrandSelfChange = (values: string[]) => {
const filtered = values.filter(Boolean);
const expanded: string[] = [];
const seen = new Set<string>();
for (const v of filtered) {
const airline = airlineOptions.find((a) => {
const primary = a.match.split("|")[0]?.trim().toLowerCase();
return primary === v.toLowerCase();
});
if (airline) {
for (const variant of airline.match.split("|").map((s) => s.trim().toLowerCase()).filter(Boolean)) {
if (!seen.has(variant)) {
seen.add(variant);
expanded.push(variant);
}
}
} else if (!seen.has(v.toLowerCase())) {
seen.add(v.toLowerCase());
expanded.push(v);
}
}
onChange(enabled, { ...effectiveConfig, brand_self: expanded });
};
if (!enabled) {
return (
<Card
title={
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<Title level={5} style={{ margin: 0 }}>
Competitor Intent Filter
</Title>
<Switch checked={false} onChange={handleEnabledChange} />
</div>
}
size="small"
>
<Text type="secondary">
Block or reframe competitor comparison questions. When enabled, airline type
auto-loads competitors from IATA; generic type requires manual competitor list.
</Text>
</Card>
);
}
return (
<Card
title={
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<Title level={5} style={{ margin: 0 }}>
Competitor Intent Filter
</Title>
<Switch checked={enabled} onChange={handleEnabledChange} />
</div>
}
size="small"
>
<Text type="secondary" style={{ display: "block", marginBottom: 16 }}>
Block or reframe competitor comparison questions. Airline type uses major airlines
(excluding your brand); generic requires manual competitor list.
</Text>
<Form layout="vertical" size="small">
<Form.Item label="Type">
<Select
value={effectiveConfig.competitor_intent_type}
onChange={(v) => handleConfigChange("competitor_intent_type", v)}
style={{ width: "100%" }}
>
<Option value="airline">Airline (auto-load competitors from IATA)</Option>
<Option value="generic">Generic (specify competitors manually)</Option>
</Select>
</Form.Item>
<Form.Item
label="Your Brand (brand_self)"
required
help={
effectiveConfig.competitor_intent_type === "airline"
? "Select your airline from the list (excluded from competitors) or type to add a custom term"
: "Names/codes users use for your brand"
}
>
<Select
mode="tags"
style={{ width: "100%" }}
placeholder={
loadingAirlines
? "Loading airlines..."
: effectiveConfig.competitor_intent_type === "airline"
? "Search or select airline, or type to add custom"
: "Type and press Enter to add"
}
value={effectiveConfig.brand_self}
onChange={(v) =>
effectiveConfig.competitor_intent_type === "airline" && airlineOptions.length > 0
? handleBrandSelfChange(v ?? [])
: handleNestedArrayChange("brand_self", v ?? [])
}
tokenSeparators={[","]}
loading={loadingAirlines}
showSearch
filterOption={(input, option) =>
(option?.label?.toString().toLowerCase() ?? "").includes(
input.toLowerCase()
)
}
optionFilterProp="label"
options={
effectiveConfig.competitor_intent_type === "airline" && airlineOptions.length > 0
? airlineOptions.map((a) => {
const primary = a.match.split("|")[0]?.trim() ?? a.id;
const variants = a.match
.split("|")
.map((s) => s.trim().toLowerCase())
.filter(Boolean);
return {
value: primary.toLowerCase(),
label: `${primary}${variants.length > 1 ? ` (${variants.slice(1).join(", ")})` : ""}`,
};
})
: undefined
}
/>
</Form.Item>
{effectiveConfig.competitor_intent_type === "airline" && (
<Form.Item
label="Locations (optional)"
help="Countries, cities, airports for disambiguation (e.g. qatar, doha)"
>
<Select
mode="tags"
style={{ width: "100%" }}
placeholder="Type and press Enter to add"
value={effectiveConfig.locations ?? []}
onChange={(v) => handleNestedArrayChange("locations", v ?? [])}
tokenSeparators={[","]}
/>
</Form.Item>
)}
{effectiveConfig.competitor_intent_type === "generic" && (
<Form.Item
label="Competitors"
required
help="Competitor names to detect (required for generic type)"
>
<Select
mode="tags"
style={{ width: "100%" }}
placeholder="Type and press Enter to add"
value={effectiveConfig.competitors ?? []}
onChange={(v) => handleNestedArrayChange("competitors", v ?? [])}
tokenSeparators={[","]}
/>
</Form.Item>
)}
<Form.Item label="Policy: Competitor comparison">
<Select
value={effectiveConfig.policy?.competitor_comparison ?? "refuse"}
onChange={(v) => handlePolicyChange("competitor_comparison", v)}
style={{ width: "100%" }}
>
<Option value="refuse">Refuse (block request)</Option>
<Option value="reframe">Reframe (suggest alternative)</Option>
</Select>
</Form.Item>
<Form.Item label="Policy: Possible competitor comparison">
<Select
value={effectiveConfig.policy?.possible_competitor_comparison ?? "reframe"}
onChange={(v) => handlePolicyChange("possible_competitor_comparison", v)}
style={{ width: "100%" }}
>
<Option value="refuse">Refuse (block request)</Option>
<Option value="reframe">Reframe (suggest alternative to backend LLM)</Option>
</Select>
</Form.Item>
<Form.Item
label="Confidence thresholds"
help={
<>
Classify competitor intent by confidence (01). Higher confidence stronger intent.
<ul style={{ marginBottom: 0, marginTop: 4, paddingLeft: 20 }}>
<li>
<strong>High ()</strong>: Treat as full competitor comparison uses &quot;Competitor comparison&quot; policy
</li>
<li>
<strong>Medium ()</strong>: Treat as possible comparison uses &quot;Possible competitor comparison&quot; policy
</li>
<li>
<strong>Low ()</strong>: Log only; allow request. Below Low allow with no action
</li>
</ul>
Raise thresholds to be more permissive; lower them to be stricter.
</>
}
>
<Space wrap>
<Form.Item label="High" style={{ marginBottom: 0 }} help="e.g. 0.7">
<InputNumber
min={0}
max={1}
step={0.05}
value={effectiveConfig.threshold_high ?? 0.7}
onChange={(v) => handleConfigChange("threshold_high", v ?? 0.7)}
style={{ width: 80 }}
/>
</Form.Item>
<Form.Item label="Medium" style={{ marginBottom: 0 }} help="e.g. 0.45">
<InputNumber
min={0}
max={1}
step={0.05}
value={effectiveConfig.threshold_medium ?? 0.45}
onChange={(v) => handleConfigChange("threshold_medium", v ?? 0.45)}
style={{ width: 80 }}
/>
</Form.Item>
<Form.Item label="Low" style={{ marginBottom: 0 }} help="e.g. 0.3">
<InputNumber
min={0}
max={1}
step={0.05}
value={effectiveConfig.threshold_low ?? 0.3}
onChange={(v) => handleConfigChange("threshold_low", v ?? 0.3)}
style={{ width: 80 }}
/>
</Form.Item>
</Space>
</Form.Item>
</Form>
</Card>
);
};
export default CompetitorIntentConfiguration;
@@ -9,6 +9,9 @@ import KeywordModal from "./KeywordModal";
import PatternTable from "./PatternTable";
import KeywordTable from "./KeywordTable";
import ContentCategoryConfiguration from "./ContentCategoryConfiguration";
import CompetitorIntentConfiguration, {
CompetitorIntentConfig,
} from "./CompetitorIntentConfiguration";
const { Title, Text } = Typography;
@@ -63,7 +66,7 @@ interface ContentFilterConfigurationProps {
onBlockedWordUpdate: (id: string, field: string, value: any) => void;
onFileUpload?: (content: string) => void;
accessToken: string | null;
showStep?: "patterns" | "keywords" | "categories";
showStep?: "patterns" | "keywords" | "categories" | "competitor_intent";
contentCategories?: ContentCategory[];
selectedContentCategories?: SelectedContentCategory[];
onContentCategoryAdd?: (category: SelectedContentCategory) => void;
@@ -71,6 +74,12 @@ interface ContentFilterConfigurationProps {
onContentCategoryUpdate?: (id: string, field: string, value: any) => void;
pendingCategorySelection?: string;
onPendingCategorySelectionChange?: (value: string) => void;
competitorIntentEnabled?: boolean;
competitorIntentConfig?: CompetitorIntentConfig | null;
onCompetitorIntentChange?: (
enabled: boolean,
config: CompetitorIntentConfig | null
) => void;
}
const ContentFilterConfiguration: React.FC<ContentFilterConfigurationProps> = ({
@@ -94,6 +103,9 @@ const ContentFilterConfiguration: React.FC<ContentFilterConfigurationProps> = ({
onContentCategoryUpdate,
pendingCategorySelection,
onPendingCategorySelectionChange,
competitorIntentEnabled = false,
competitorIntentConfig = null,
onCompetitorIntentChange,
}) => {
const [patternModalVisible, setPatternModalVisible] = useState(false);
const [keywordModalVisible, setKeywordModalVisible] = useState(false);
@@ -197,6 +209,8 @@ const ContentFilterConfiguration: React.FC<ContentFilterConfigurationProps> = ({
const showPatterns = !showStep || showStep === "patterns";
const showKeywords = !showStep || showStep === "keywords";
const showCategories = !showStep || showStep === "categories";
const showCompetitorIntent =
!showStep || showStep === "competitor_intent" || showStep === "categories";
return (
<div className="space-y-6">
@@ -274,6 +288,16 @@ const ContentFilterConfiguration: React.FC<ContentFilterConfigurationProps> = ({
</Card>
)}
{showCompetitorIntent &&
onCompetitorIntentChange && (
<CompetitorIntentConfiguration
enabled={competitorIntentEnabled}
config={competitorIntentConfig}
onChange={onCompetitorIntentChange}
accessToken={accessToken}
/>
)}
{showCategories && contentCategories.length > 0 && onContentCategoryAdd && onContentCategoryRemove && onContentCategoryUpdate && (
<ContentCategoryConfiguration
availableCategories={contentCategories}
@@ -384,7 +384,7 @@ describe("ContentFilterManager", () => {
);
await waitFor(() => {
expect(mockOnDataChange).toHaveBeenCalledWith([], [], []);
expect(mockOnDataChange).toHaveBeenCalledWith([], [], [], false, null);
});
});
@@ -2,6 +2,7 @@ import { Alert, Divider, Typography } from "antd";
import React, { useEffect, useState } from "react";
import ContentFilterConfiguration from "./ContentFilterConfiguration";
import ContentFilterDisplay from "./ContentFilterDisplay";
import type { CompetitorIntentConfig } from "./CompetitorIntentConfiguration";
const { Text } = Typography
@@ -55,7 +56,13 @@ interface ContentFilterManagerProps {
guardrailSettings: GuardrailSettings | null;
isEditing: boolean;
accessToken: string | null;
onDataChange?: (patterns: Pattern[], blockedWords: BlockedWord[], categories: SelectedContentCategory[]) => void;
onDataChange?: (
patterns: Pattern[],
blockedWords: BlockedWord[],
categories: SelectedContentCategory[],
competitorIntentEnabled?: boolean,
competitorIntentConfig?: CompetitorIntentConfig | null
) => void;
onUnsavedChanges?: (hasChanges: boolean) => void;
}
@@ -73,6 +80,10 @@ const ContentFilterManager: React.FC<ContentFilterManagerProps> = ({
const [originalPatterns, setOriginalPatterns] = useState<Pattern[]>([]);
const [originalBlockedWords, setOriginalBlockedWords] = useState<BlockedWord[]>([]);
const [originalContentCategories, setOriginalContentCategories] = useState<SelectedContentCategory[]>([]);
const [competitorIntentEnabled, setCompetitorIntentEnabled] = useState(false);
const [competitorIntentConfig, setCompetitorIntentConfig] = useState<CompetitorIntentConfig | null>(null);
const [originalCompetitorIntentEnabled, setOriginalCompetitorIntentEnabled] = useState(false);
const [originalCompetitorIntentConfig, setOriginalCompetitorIntentConfig] = useState<CompetitorIntentConfig | null>(null);
// Load data from guardrail on mount or when guardrailData changes
useEffect(() => {
@@ -128,22 +139,73 @@ const ContentFilterManager: React.FC<ContentFilterManagerProps> = ({
setSelectedContentCategories([]);
setOriginalContentCategories([]);
}
const cic = guardrailData?.litellm_params?.competitor_intent_config;
if (cic && typeof cic === "object") {
const enabled = !!(cic.brand_self && Array.isArray(cic.brand_self) && cic.brand_self.length > 0);
const config: CompetitorIntentConfig = {
competitor_intent_type: cic.competitor_intent_type ?? "airline",
brand_self: Array.isArray(cic.brand_self) ? cic.brand_self : [],
locations: Array.isArray(cic.locations) ? cic.locations : [],
competitors: Array.isArray(cic.competitors) ? cic.competitors : [],
policy: cic.policy ?? { competitor_comparison: "refuse", possible_competitor_comparison: "reframe" },
threshold_high: typeof cic.threshold_high === "number" ? cic.threshold_high : 0.7,
threshold_medium: typeof cic.threshold_medium === "number" ? cic.threshold_medium : 0.45,
threshold_low: typeof cic.threshold_low === "number" ? cic.threshold_low : 0.3,
};
setCompetitorIntentEnabled(enabled);
setCompetitorIntentConfig(config);
setOriginalCompetitorIntentEnabled(enabled);
setOriginalCompetitorIntentConfig(config);
} else {
setCompetitorIntentEnabled(false);
setCompetitorIntentConfig(null);
setOriginalCompetitorIntentEnabled(false);
setOriginalCompetitorIntentConfig(null);
}
}, [guardrailData, guardrailSettings?.content_filter_settings?.content_categories]);
// Notify parent component when data changes
useEffect(() => {
if (onDataChange) {
onDataChange(selectedPatterns, blockedWords, selectedContentCategories);
onDataChange(
selectedPatterns,
blockedWords,
selectedContentCategories,
competitorIntentEnabled,
competitorIntentConfig
);
}
}, [selectedPatterns, blockedWords, selectedContentCategories, onDataChange]);
}, [
selectedPatterns,
blockedWords,
selectedContentCategories,
competitorIntentEnabled,
competitorIntentConfig,
onDataChange,
]);
// Detect unsaved changes
const hasUnsavedChanges = React.useMemo(() => {
const hasPatternChanges = JSON.stringify(selectedPatterns) !== JSON.stringify(originalPatterns);
const hasWordChanges = JSON.stringify(blockedWords) !== JSON.stringify(originalBlockedWords);
const hasCategoryChanges = JSON.stringify(selectedContentCategories) !== JSON.stringify(originalContentCategories);
return hasPatternChanges || hasWordChanges || hasCategoryChanges;
}, [selectedPatterns, blockedWords, selectedContentCategories, originalPatterns, originalBlockedWords, originalContentCategories]);
const hasCompetitorIntentChanges =
competitorIntentEnabled !== originalCompetitorIntentEnabled ||
JSON.stringify(competitorIntentConfig) !== JSON.stringify(originalCompetitorIntentConfig);
return hasPatternChanges || hasWordChanges || hasCategoryChanges || hasCompetitorIntentChanges;
}, [
selectedPatterns,
blockedWords,
selectedContentCategories,
competitorIntentEnabled,
competitorIntentConfig,
originalPatterns,
originalBlockedWords,
originalContentCategories,
originalCompetitorIntentEnabled,
originalCompetitorIntentConfig,
]);
useEffect(() => {
if (isEditing && onUnsavedChanges) {
@@ -219,6 +281,12 @@ const ContentFilterManager: React.FC<ContentFilterManagerProps> = ({
selectedContentCategories.map((c) => (c.id === id ? { ...c, [field]: value } : c))
)
}
competitorIntentEnabled={competitorIntentEnabled}
competitorIntentConfig={competitorIntentConfig}
onCompetitorIntentChange={(enabled, config) => {
setCompetitorIntentEnabled(enabled);
setCompetitorIntentConfig(config);
}}
/>
)}
</div>
@@ -232,12 +300,15 @@ export default ContentFilterManager;
export const formatContentFilterDataForAPI = (
patterns: Pattern[],
blockedWords: BlockedWord[],
categories?: SelectedContentCategory[]
categories?: SelectedContentCategory[],
competitorIntentEnabled?: boolean,
competitorIntentConfig?: CompetitorIntentConfig | null
) => {
const result: {
patterns: any[];
blocked_words: any[];
categories?: any[];
competitor_intent_config?: any;
} = {
patterns: patterns.map((p) => ({
pattern_type: p.type === "prebuilt" ? "prebuilt" : "regex",
@@ -260,5 +331,23 @@ export const formatContentFilterDataForAPI = (
severity_threshold: c.severity_threshold || "medium",
}));
}
if (competitorIntentEnabled && competitorIntentConfig && competitorIntentConfig.brand_self.length > 0) {
result.competitor_intent_config = {
competitor_intent_type: competitorIntentConfig.competitor_intent_type,
brand_self: competitorIntentConfig.brand_self,
locations: competitorIntentConfig.locations?.length
? competitorIntentConfig.locations
: undefined,
competitors:
competitorIntentConfig.competitor_intent_type === "generic" &&
competitorIntentConfig.competitors?.length
? competitorIntentConfig.competitors
: undefined,
policy: competitorIntentConfig.policy,
threshold_high: competitorIntentConfig.threshold_high,
threshold_medium: competitorIntentConfig.threshold_medium,
threshold_low: competitorIntentConfig.threshold_low,
};
}
return result;
};
@@ -106,6 +106,8 @@ const GuardrailInfoView: React.FC<GuardrailInfoProps> = ({ guardrailId, onClose,
patterns: any[];
blockedWords: any[];
categories: any[];
competitorIntentEnabled?: boolean;
competitorIntentConfig?: any;
}>({
patterns: [],
blockedWords: [],
@@ -113,9 +115,24 @@ const GuardrailInfoView: React.FC<GuardrailInfoProps> = ({ guardrailId, onClose,
});
// Memoize onDataChange callback to prevent unnecessary re-renders
const handleContentFilterDataChange = useCallback((patterns: any[], blockedWords: any[], categories: any[]) => {
contentFilterDataRef.current = { patterns, blockedWords, categories: categories || [] };
}, []);
const handleContentFilterDataChange = useCallback(
(
patterns: any[],
blockedWords: any[],
categories: any[],
competitorIntentEnabled?: boolean,
competitorIntentConfig?: any
) => {
contentFilterDataRef.current = {
patterns,
blockedWords,
categories: categories || [],
competitorIntentEnabled,
competitorIntentConfig,
};
},
[]
);
const fetchGuardrailInfo = async () => {
try {
@@ -287,11 +304,15 @@ const GuardrailInfoView: React.FC<GuardrailInfoProps> = ({ guardrailId, onClose,
contentFilterDataRef.current.patterns || [],
contentFilterDataRef.current.blockedWords || [],
contentFilterDataRef.current.categories || [],
contentFilterDataRef.current.competitorIntentEnabled,
contentFilterDataRef.current.competitorIntentConfig
);
updateData.litellm_params.patterns = formattedData.patterns;
updateData.litellm_params.blocked_words = formattedData.blocked_words;
updateData.litellm_params.categories = formattedData.categories;
updateData.litellm_params.competitor_intent_config =
formattedData.competitor_intent_config ?? null;
}
if (guardrailData.litellm_params?.guardrail === "tool_permission") {
@@ -5438,10 +5438,19 @@ export const getPoliciesList = async (accessToken: string) => {
}
};
export interface GuardrailInputs {
texts?: string[];
images?: string[];
[key: string]: unknown;
}
export interface TestPoliciesAndGuardrailsRequest {
policy_names?: string[] | null;
guardrail_names?: string[] | null;
inputs: { texts?: string[]; images?: string[]; [key: string]: unknown };
/** Single input (legacy). Use inputs_list for per-input batch processing. */
inputs?: GuardrailInputs | null;
/** List of inputs; each processed separately for batch compliance testing. */
inputs_list?: GuardrailInputs[] | null;
request_data?: Record<string, unknown>;
input_type?: "request" | "response";
}
@@ -5452,8 +5461,10 @@ export interface GuardrailErrorEntry {
}
export interface TestPoliciesAndGuardrailsResponse {
inputs: Record<string, unknown>;
guardrail_errors: GuardrailErrorEntry[];
inputs?: Record<string, unknown>;
guardrail_errors?: GuardrailErrorEntry[];
/** Present when inputs_list was used; one result per input. */
results?: Array<{ inputs: Record<string, unknown>; guardrail_errors: GuardrailErrorEntry[] }>;
}
export const testPoliciesAndGuardrails = async (
@@ -5473,7 +5484,8 @@ export const testPoliciesAndGuardrails = async (
body: JSON.stringify({
policy_names: body.policy_names ?? null,
guardrail_names: body.guardrail_names ?? null,
inputs: body.inputs,
inputs: body.inputs ?? null,
inputs_list: body.inputs_list ?? null,
request_data: body.request_data ?? {},
input_type: body.input_type ?? "request",
}),
@@ -7805,6 +7817,38 @@ export const getCategoryYaml = async (accessToken: string, categoryName: string)
}
};
export const getMajorAirlines = async (accessToken: string) => {
try {
const url = proxyBaseUrl
? `${proxyBaseUrl}/guardrails/ui/major_airlines`
: `/guardrails/ui/major_airlines`;
const response = await fetch(url, {
method: "GET",
headers: {
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
});
if (!response.ok) {
const errorData = await response.text();
console.error(
`Failed to get major airlines. Status: ${response.status}, Error:`,
errorData
);
handleError(errorData);
throw new Error(`Failed to get major airlines: ${response.status} ${errorData}`);
}
const data = await response.json();
return data;
} catch (error) {
console.error("Failed to get major airlines:", error);
throw error;
}
};
export const getAgentsList = async (accessToken: string) => {
try {
const url = proxyBaseUrl ? `${proxyBaseUrl}/v1/agents` : `/v1/agents`;
@@ -445,7 +445,7 @@ export default function ComplianceUI({
setQuickTestInput("");
setIsQuickTesting(true);
try {
const { inputs, guardrail_errors } = await testPoliciesAndGuardrails(
const { inputs, guardrail_errors = [] } = await testPoliciesAndGuardrails(
accessToken,
{
policy_names:
@@ -532,50 +532,51 @@ export default function ComplianceUI({
status: "pending",
}));
setTestResults(pendingResults);
// Send each text individually to get per-text blocked/allowed results.
// Sending all texts in a single batch doesn't work because the guardrail
// raises an HTTPException on the first blocked text, skipping the rest.
const updatedResults = [...pendingResults];
for (let i = 0; i < allTexts.length; i++) {
try {
const { inputs, guardrail_errors } = await testPoliciesAndGuardrails(
accessToken,
{
policy_names:
selectedPolicies.length > 0 ? selectedPolicies : undefined,
guardrail_names:
selectedGuardrails.length > 0 ? selectedGuardrails : undefined,
inputs: { texts: [allTexts[i]] },
request_data: {},
input_type: "request",
}
);
const actualResult: "blocked" | "allowed" =
guardrail_errors.length > 0 ? "blocked" : "allowed";
const triggeredBy =
guardrail_errors.length > 0
? guardrail_errors
.map((e) => `${e.guardrail_name}: ${e.message}`)
.join("; ")
: undefined;
const returnedText =
Array.isArray(inputs?.texts) && inputs.texts.length > 0
? inputs.texts[0]
: undefined;
updatedResults[i] = {
...updatedResults[i],
actualResult,
isMatch:
(updatedResults[i].expectedResult === "fail" && actualResult === "blocked") ||
(updatedResults[i].expectedResult === "pass" && actualResult === "allowed"),
triggeredBy,
returnedText,
status: "complete" as const,
};
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
updatedResults[i] = {
...updatedResults[i],
try {
const inputsList = allTexts.map((text) => ({ texts: [text] }));
const response = await testPoliciesAndGuardrails(accessToken, {
policy_names:
selectedPolicies.length > 0 ? selectedPolicies : undefined,
guardrail_names:
selectedGuardrails.length > 0 ? selectedGuardrails : undefined,
inputs_list: inputsList,
request_data: {},
input_type: "request",
});
const results = response.results ?? [];
setTestResults(
pendingResults.map((row, index) => {
const item = results[index];
const guardrail_errors = item?.guardrail_errors ?? [];
const actualResult: "blocked" | "allowed" =
guardrail_errors.length > 0 ? "blocked" : "allowed";
const triggeredBy =
guardrail_errors.length > 0
? guardrail_errors
.map((e) => `${e.guardrail_name}: ${e.message}`)
.join("; ")
: undefined;
const returnedText =
Array.isArray(item?.inputs?.texts) && item.inputs.texts.length > 0
? item.inputs.texts[0]
: undefined;
return {
...row,
actualResult,
isMatch:
(row.expectedResult === "fail" && actualResult === "blocked") ||
(row.expectedResult === "pass" && actualResult === "allowed"),
triggeredBy,
returnedText,
status: "complete" as const,
};
})
);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
setTestResults(
pendingResults.map((row) => ({
...row,
actualResult: "blocked" as const,
isMatch: false,
triggeredBy: `Error: ${errorMessage}`,
@@ -596,6 +597,12 @@ export default function ComplianceUI({
const completedResults = testResults.filter((r) => r.status === "complete");
const matchCount = completedResults.filter((r) => r.isMatch).length;
const mismatchCount = completedResults.filter((r) => !r.isMatch).length;
const falsePositiveCount = completedResults.filter(
(r) => r.expectedResult === "pass" && r.actualResult === "blocked"
).length;
const falseNegativeCount = completedResults.filter(
(r) => r.expectedResult === "fail" && r.actualResult === "allowed"
).length;
const pendingCount = testResults.filter((r) => r.status !== "complete").length;
const filteredResults = testResults.filter((r) => {
if (resultFilter === "matches") return r.status === "complete" && r.isMatch;
@@ -604,6 +611,31 @@ export default function ComplianceUI({
return true;
});
const exportBatchResults = () => {
if (filteredResults.length === 0) return;
const rows = filteredResults.map((r) => ({
prompt_id: r.promptId,
prompt: r.prompt,
category: r.category,
expected_result: r.expectedResult,
actual_result: r.actualResult,
is_match: r.isMatch ? "yes" : "no",
status: r.status,
triggered_by: r.triggeredBy ?? "",
returned_text: r.returnedText ?? "",
}));
const csv = Papa.unparse(rows);
const blob = new Blob([csv], { type: "text/csv" });
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `compliance_batch_results_${new Date().toISOString().slice(0, 10)}.csv`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
};
const filteredFrameworks = allFrameworks
.map((fw) => ({
...fw,
@@ -1365,14 +1397,33 @@ export default function ComplianceUI({
<div className="flex items-center justify-between mb-2">
<h2 className="text-sm font-semibold text-gray-900">Results</h2>
{testResults.length > 0 && (
<div className="flex items-center gap-2.5 text-[11px]">
<div className="flex items-center gap-2">
<button
type="button"
onClick={exportBatchResults}
disabled={filteredResults.length === 0}
className="flex items-center gap-1 text-[11px] font-medium text-gray-600 hover:text-gray-900 hover:bg-gray-100 px-2 py-1 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-transparent"
>
<Download className="w-3 h-3" /> Export CSV
</button>
<div className="flex items-center gap-2.5 text-[11px]">
<span className="flex items-center gap-1 text-green-600">
<CheckCircle2 className="w-3 h-3" />
{matchCount}
</span>
<span className="flex items-center gap-1 text-red-600">
<span
className="flex items-center gap-1 text-amber-600"
title="Allowed content that should have been blocked"
>
<AlertTriangle className="w-3 h-3" />
{falseNegativeCount} FN
</span>
<span
className="flex items-center gap-1 text-red-600"
title="Blocked content that should have been allowed"
>
<X className="w-3 h-3" />
{mismatchCount}
{falsePositiveCount} FP
</span>
{pendingCount > 0 && (
<span className="flex items-center gap-1 text-gray-500">
@@ -1381,6 +1432,7 @@ export default function ComplianceUI({
</span>
)}
</div>
</div>
)}
</div>
{testResults.length > 0 && (
@@ -1443,11 +1495,18 @@ export default function ComplianceUI({
<span className="text-gray-500">correct</span>
</span>
<div className="w-px h-4 bg-gray-200" />
<span>
<span className="font-semibold text-red-700">
{mismatchCount}
<span title="Allowed content that should have been blocked">
<span className="font-semibold text-amber-700">
{falseNegativeCount}
</span>{" "}
<span className="text-gray-500">gaps</span>
<span className="text-gray-500">false negative</span>
</span>
<div className="w-px h-4 bg-gray-200" />
<span title="Blocked content that should have been allowed">
<span className="font-semibold text-red-700">
{falsePositiveCount}
</span>{" "}
<span className="text-gray-500">false positive</span>
</span>
</div>
<div
@@ -250,10 +250,254 @@ const compliancePrompts: CompliancePrompt[] = [
),
];
export const airlineCompliancePrompts: CompliancePrompt[] = [
// =====================
// DESTINATION (should PASS)
// =====================
{
id: "airline_001",
framework: "Airline Brand Protection",
category: "Destination Intent",
categoryIcon: "✈️",
categoryDescription: "User is asking about a travel destination, not a competitor airline.",
prompt: "Do you have flights to Qatar?",
expectedResult: "pass",
},
{
id: "airline_002",
framework: "Airline Brand Protection",
category: "Destination Intent",
categoryIcon: "✈️",
categoryDescription: "Location-based travel planning.",
prompt: "How long is the flight to Doha?",
expectedResult: "pass",
},
{
id: "airline_003",
framework: "Airline Brand Protection",
category: "Destination Intent",
categoryIcon: "✈️",
categoryDescription: "Transit and visa questions for a country.",
prompt: "Do I need a visa to transit through Qatar?",
expectedResult: "pass",
},
{
id: "airline_004",
framework: "Airline Brand Protection",
category: "Destination Intent",
categoryIcon: "✈️",
categoryDescription: "Airport facilities question.",
prompt: "Are there lounges at Hamad Airport?",
expectedResult: "pass",
},
{
id: "airline_005",
framework: "Airline Brand Protection",
category: "Destination Intent",
categoryIcon: "✈️",
categoryDescription: "Travel logistics to a location.",
prompt: "What documents are required to enter Singapore?",
expectedResult: "pass",
},
{
id: "airline_006",
framework: "Airline Brand Protection",
category: "Destination Intent",
categoryIcon: "✈️",
categoryDescription: "Flight planning for a country destination.",
prompt: "What airlines fly to Turkey?",
expectedResult: "pass",
},
{
id: "airline_007",
framework: "Airline Brand Protection",
category: "Destination Intent",
categoryIcon: "✈️",
categoryDescription: "Airport code information.",
prompt: "What is the airport code for Doha?",
expectedResult: "pass",
},
{
id: "airline_008",
framework: "Airline Brand Protection",
category: "Destination Intent",
categoryIcon: "✈️",
categoryDescription: "General travel planning question.",
prompt: "Is Doha a good place for a long layover?",
expectedResult: "pass",
},
{
id: "airline_009",
framework: "Airline Brand Protection",
category: "Destination Intent",
categoryIcon: "✈️",
categoryDescription: "Country weather question.",
prompt: "What is the weather like in Qatar in March?",
expectedResult: "pass",
},
{
id: "airline_010",
framework: "Airline Brand Protection",
category: "Destination Intent",
categoryIcon: "✈️",
categoryDescription: "Transit timing question.",
prompt: "How much connection time do I need in Doha?",
expectedResult: "pass",
},
// =====================
// COMPETITOR (should FAIL)
// =====================
{
id: "airline_011",
framework: "Airline Brand Protection",
category: "Competitor Comparison",
categoryIcon: "⚠️",
categoryDescription: "Direct comparison between airlines.",
prompt: "Is Qatar Airways better than Emirates?",
expectedResult: "fail",
},
{
id: "airline_012",
framework: "Airline Brand Protection",
category: "Competitor Comparison",
categoryIcon: "⚠️",
categoryDescription: "Comparison request for premium cabins.",
prompt: "Which airline has better business class, Qatar or Singapore Airlines?",
expectedResult: "fail",
},
{
id: "airline_013",
framework: "Airline Brand Protection",
category: "Competitor Comparison",
categoryIcon: "⚠️",
categoryDescription: "Airline ranking request.",
prompt: "Which airline is the best for long haul flights?",
expectedResult: "fail",
},
{
id: "airline_014",
framework: "Airline Brand Protection",
category: "Competitor Comparison",
categoryIcon: "⚠️",
categoryDescription: "Decision-making between competitors.",
prompt: "Should I choose Qatar Airways or another airline?",
expectedResult: "fail",
},
{
id: "airline_015",
framework: "Airline Brand Protection",
category: "Competitor Comparison",
categoryIcon: "⚠️",
categoryDescription: "General airline comparison.",
prompt: "Compare Turkish Airlines vs Lufthansa.",
expectedResult: "fail",
},
{
id: "airline_016",
framework: "Airline Brand Protection",
category: "Competitor Comparison",
categoryIcon: "⚠️",
categoryDescription: "Service quality comparison.",
prompt: "Which airline has better lounges?",
expectedResult: "fail",
},
{
id: "airline_017",
framework: "Airline Brand Protection",
category: "Competitor Comparison",
categoryIcon: "⚠️",
categoryDescription: "Customer review comparison.",
prompt: "Which airline has the best customer satisfaction?",
expectedResult: "fail",
},
{
id: "airline_018",
framework: "Airline Brand Protection",
category: "Competitor Comparison",
categoryIcon: "⚠️",
categoryDescription: "Indirect competitor reference via location.",
prompt: "Is the Doha airline better than other carriers?",
expectedResult: "fail",
},
{
id: "airline_019",
framework: "Airline Brand Protection",
category: "Competitor Comparison",
categoryIcon: "⚠️",
categoryDescription: "Switching intent to competitor.",
prompt: "Should I switch to another airline for a better experience?",
expectedResult: "fail",
},
{
id: "airline_020",
framework: "Airline Brand Protection",
category: "Competitor Comparison",
categoryIcon: "⚠️",
categoryDescription: "Global airline ranking request.",
prompt: "Which airline is ranked number one worldwide?",
expectedResult: "fail",
},
// =====================
// AMBIGUOUS (should PASS but needs careful handling)
// =====================
{
id: "airline_021",
framework: "Airline Brand Protection",
category: "Ambiguous Entity",
categoryIcon: "❓",
categoryDescription: "Could refer to airline or country.",
prompt: "Qatar baggage allowance",
expectedResult: "pass",
},
{
id: "airline_022",
framework: "Airline Brand Protection",
category: "Ambiguous Entity",
categoryIcon: "❓",
categoryDescription: "Ambiguous lounge reference.",
prompt: "Qatar lounge access rules",
expectedResult: "pass",
},
{
id: "airline_023",
framework: "Airline Brand Protection",
category: "Ambiguous Entity",
categoryIcon: "❓",
categoryDescription: "Ambiguous operational query.",
prompt: "Qatar check in time",
expectedResult: "pass",
},
{
id: "airline_024",
framework: "Airline Brand Protection",
category: "Ambiguous Entity",
categoryIcon: "❓",
categoryDescription: "Ambiguous airline vs destination reference.",
prompt: "Doha premium lounge access",
expectedResult: "pass",
},
{
id: "airline_025",
framework: "Airline Brand Protection",
category: "Ambiguous Entity",
categoryIcon: "❓",
categoryDescription: "Ambiguous refund context.",
prompt: "Qatar refund policy",
expectedResult: "pass",
},
];
const allCompliancePrompts: CompliancePrompt[] = [
...compliancePrompts,
...airlineCompliancePrompts,
];
export function getCategories(): ComplianceCategory[] {
const categoryMap = new Map<string, ComplianceCategory>();
for (const prompt of compliancePrompts) {
for (const prompt of allCompliancePrompts) {
if (!categoryMap.has(prompt.category)) {
categoryMap.set(prompt.category, {
name: prompt.category,
@@ -277,6 +521,10 @@ const frameworkMeta: Record<string, { icon: string; description: string }> = {
icon: "lock",
description: "General Data Protection Regulation — data privacy and protection requirements.",
},
"Airline Brand Protection": {
icon: "plane",
description: "Destination vs competitor intent — avoid answering competitor comparison questions.",
},
};
export function getFrameworks(): ComplianceFramework[] {
@@ -285,7 +533,7 @@ export function getFrameworks(): ComplianceFramework[] {
{ categories: Map<string, ComplianceCategory> }
>();
for (const prompt of compliancePrompts) {
for (const prompt of allCompliancePrompts) {
if (!frameworkMap.has(prompt.framework)) {
frameworkMap.set(prompt.framework, { categories: new Map() });
}
+1 -1
View File
@@ -14,7 +14,7 @@
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"jsx": "preserve",
"incremental": true,
"plugins": [
{