mirror of
https://github.com/tiennm99/litellm.git
synced 2026-07-21 00:21:38 +00:00
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:
@@ -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)
|
||||
|
||||
+17
@@ -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",
|
||||
]
|
||||
+211
@@ -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
|
||||
+272
@@ -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,
|
||||
}
|
||||
+2459
File diff suppressed because it is too large
Load Diff
+164
-53
@@ -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 doesn’t 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"
|
||||
|
||||
+314
@@ -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();
|
||||
|
||||
+335
@@ -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 (0–1). Higher confidence → stronger intent.
|
||||
<ul style={{ marginBottom: 0, marginTop: 4, paddingLeft: 20 }}>
|
||||
<li>
|
||||
<strong>High (≥)</strong>: Treat as full competitor comparison → uses "Competitor comparison" policy
|
||||
</li>
|
||||
<li>
|
||||
<strong>Medium (≥)</strong>: Treat as possible comparison → uses "Possible competitor comparison" 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;
|
||||
+25
-1
@@ -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}
|
||||
|
||||
+1
-1
@@ -384,7 +384,7 @@ describe("ContentFilterManager", () => {
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnDataChange).toHaveBeenCalledWith([], [], []);
|
||||
expect(mockOnDataChange).toHaveBeenCalledWith([], [], [], false, null);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+95
-6
@@ -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() });
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user