mirror of
https://github.com/tiennm99/litellm.git
synced 2026-08-08 12:24:04 +00:00
Competitor guardrails: streaming discovery, variations, pre/post split (#21533)
* fix aviation safety topic filter: remove overly broad exceptions, add cockpit access block words * fix airline brand protection filter: add identifier words, competitor/ops block words, tighten exceptions * update policy templates with competitor pre/post guardrails and streaming enrichment * sync policy_templates_backup.json with policy_templates.json * add streaming enrichment endpoint, competitor variations, and model selection * add streaming enrichPolicyTemplate networking function * rewrite template parameter modal with streaming tags, AI/manual toggle, model selector * update policies panel to pass enrichment options and show discovered competitors * show discovered competitors as tags in guardrail selection modal * use llm_router instead of litellm.acompletion, extract helpers, move constants * validate competitors list size, cap variation prompt input * add refinement instruction support for competitor discovery * add instruction and existingCompetitors params to streaming enrichment * add refinement input for iterating on competitor list with AI * emit status events during variation generation so UI shows progress * add onStatus callback to streaming enrichment * show status spinner during variation generation, widen modal to 700px * add tests for competitor enrichment helper functions
This commit is contained in:
@@ -1474,3 +1474,8 @@ MICROSOFT_USER_FIRST_NAME_ATTRIBUTE = str(
|
||||
MICROSOFT_USER_LAST_NAME_ATTRIBUTE = str(
|
||||
os.getenv("MICROSOFT_USER_LAST_NAME_ATTRIBUTE", "surname")
|
||||
)
|
||||
|
||||
# Policy template enrichment
|
||||
MAX_COMPETITOR_NAMES = 100
|
||||
COMPETITOR_LLM_TEMPERATURE = 0.3
|
||||
DEFAULT_COMPETITOR_DISCOVERY_MODEL = "gpt-4o-mini"
|
||||
|
||||
@@ -1522,7 +1522,13 @@
|
||||
"guardrails": [
|
||||
"aviation-ops-data-protection",
|
||||
"aviation-safety-topic-filter",
|
||||
"airline-brand-protection-filter"
|
||||
"airline-brand-protection-filter",
|
||||
"competitor-name-input-blocker",
|
||||
"competitor-name-output-blocker",
|
||||
"competitor-recommendation-input-filter",
|
||||
"competitor-recommendation-output-filter",
|
||||
"competitor-comparison-input-filter",
|
||||
"competitor-comparison-output-filter"
|
||||
],
|
||||
"complexity": "High",
|
||||
"parameters": [
|
||||
@@ -1531,9 +1537,14 @@
|
||||
"label": "Your Airline / Brand Name",
|
||||
"type": "text",
|
||||
"required": true,
|
||||
"placeholder": "e.g. Emirates"
|
||||
"placeholder": "e.g. Acme Airlines"
|
||||
}
|
||||
],
|
||||
"llm_enrichment": {
|
||||
"parameter": "brand_name",
|
||||
"prompt": "List the top 30 direct competitors of {{brand_name}} in the airline industry. Include major international carriers, regional competitors, and low-cost carriers that operate on overlapping routes. Return ONLY airline/brand names, one per line, no numbering, no explanations.",
|
||||
"result_key": "competitors"
|
||||
},
|
||||
"guardrailDefinitions": [
|
||||
{
|
||||
"guardrail_name": "aviation-ops-data-protection",
|
||||
@@ -1675,6 +1686,72 @@
|
||||
"guardrail_info": {
|
||||
"description": "Blocks AI-generated fake incident reports, unauthorized statements, and reputation-damaging content about your brand (runs on output)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"guardrail_name": "competitor-name-input-blocker",
|
||||
"litellm_params": {
|
||||
"guardrail": "litellm_content_filter",
|
||||
"mode": "pre_call",
|
||||
"blocked_words": "{{competitors_blocked_words}}"
|
||||
},
|
||||
"guardrail_info": {
|
||||
"description": "Blocks user inputs that mention competitor names (pre_call)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"guardrail_name": "competitor-name-output-blocker",
|
||||
"litellm_params": {
|
||||
"guardrail": "litellm_content_filter",
|
||||
"mode": "post_call",
|
||||
"blocked_words": "{{competitors_blocked_words}}"
|
||||
},
|
||||
"guardrail_info": {
|
||||
"description": "Blocks AI outputs that mention competitor names (post_call)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"guardrail_name": "competitor-recommendation-input-filter",
|
||||
"litellm_params": {
|
||||
"guardrail": "litellm_content_filter",
|
||||
"mode": "pre_call",
|
||||
"blocked_words": "{{competitor_recommendation_words}}"
|
||||
},
|
||||
"guardrail_info": {
|
||||
"description": "Blocks user requests asking to recommend competitors (pre_call)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"guardrail_name": "competitor-recommendation-output-filter",
|
||||
"litellm_params": {
|
||||
"guardrail": "litellm_content_filter",
|
||||
"mode": "post_call",
|
||||
"blocked_words": "{{competitor_recommendation_words}}"
|
||||
},
|
||||
"guardrail_info": {
|
||||
"description": "Blocks AI from recommending or suggesting competitor services (post_call)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"guardrail_name": "competitor-comparison-input-filter",
|
||||
"litellm_params": {
|
||||
"guardrail": "litellm_content_filter",
|
||||
"mode": "pre_call",
|
||||
"blocked_words": "{{competitor_comparison_words}}"
|
||||
},
|
||||
"guardrail_info": {
|
||||
"description": "Blocks user inputs requesting unfavorable brand comparisons (pre_call)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"guardrail_name": "competitor-comparison-output-filter",
|
||||
"litellm_params": {
|
||||
"guardrail": "litellm_content_filter",
|
||||
"mode": "post_call",
|
||||
"blocked_words": "{{competitor_comparison_words}}"
|
||||
},
|
||||
"guardrail_info": {
|
||||
"description": "Blocks AI outputs with unfavorable brand comparisons (post_call)"
|
||||
}
|
||||
}
|
||||
],
|
||||
"templateData": {
|
||||
@@ -1683,7 +1760,13 @@
|
||||
"guardrails_add": [
|
||||
"aviation-ops-data-protection",
|
||||
"aviation-safety-topic-filter",
|
||||
"airline-brand-protection-filter"
|
||||
"airline-brand-protection-filter",
|
||||
"competitor-name-input-blocker",
|
||||
"competitor-name-output-blocker",
|
||||
"competitor-recommendation-input-filter",
|
||||
"competitor-recommendation-output-filter",
|
||||
"competitor-comparison-input-filter",
|
||||
"competitor-comparison-output-filter"
|
||||
],
|
||||
"guardrails_remove": []
|
||||
},
|
||||
@@ -1812,9 +1895,12 @@
|
||||
"iconColor": "text-orange-500",
|
||||
"iconBg": "bg-orange-50",
|
||||
"guardrails": [
|
||||
"competitor-input-blocker",
|
||||
"competitor-output-blocker",
|
||||
"competitor-recommendation-filter",
|
||||
"competitor-comparison-filter"
|
||||
"competitor-recommendation-input-filter",
|
||||
"competitor-recommendation-output-filter",
|
||||
"competitor-comparison-input-filter",
|
||||
"competitor-comparison-output-filter"
|
||||
],
|
||||
"complexity": "Medium",
|
||||
"parameters": [
|
||||
@@ -1823,15 +1909,26 @@
|
||||
"label": "Your Brand Name",
|
||||
"type": "text",
|
||||
"required": true,
|
||||
"placeholder": "e.g. Emirates"
|
||||
"placeholder": "e.g. Acme Airlines"
|
||||
}
|
||||
],
|
||||
"llm_enrichment": {
|
||||
"parameter": "brand_name",
|
||||
"prompt": "List the top 10 direct competitors of {{brand_name}} in the same industry. Return ONLY company/brand names, one per line, no numbering, no explanations.",
|
||||
"prompt": "List the top 30 direct competitors of {{brand_name}} in the same industry. Return ONLY company/brand names, one per line, no numbering, no explanations.",
|
||||
"result_key": "competitors"
|
||||
},
|
||||
"guardrailDefinitions": [
|
||||
{
|
||||
"guardrail_name": "competitor-input-blocker",
|
||||
"litellm_params": {
|
||||
"guardrail": "litellm_content_filter",
|
||||
"mode": "pre_call",
|
||||
"blocked_words": "{{competitors_blocked_words}}"
|
||||
},
|
||||
"guardrail_info": {
|
||||
"description": "Blocks user inputs that mention competitor brands (pre_call)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"guardrail_name": "competitor-output-blocker",
|
||||
"litellm_params": {
|
||||
@@ -1840,39 +1937,64 @@
|
||||
"blocked_words": "{{competitors_blocked_words}}"
|
||||
},
|
||||
"guardrail_info": {
|
||||
"description": "Blocks AI outputs that mention or promote competitor brands (auto-discovered via LLM)"
|
||||
"description": "Blocks AI outputs that mention competitor brands (post_call)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"guardrail_name": "competitor-recommendation-filter",
|
||||
"guardrail_name": "competitor-recommendation-input-filter",
|
||||
"litellm_params": {
|
||||
"guardrail": "litellm_content_filter",
|
||||
"mode": "pre_call",
|
||||
"blocked_words": "{{competitor_recommendation_words}}"
|
||||
},
|
||||
"guardrail_info": {
|
||||
"description": "Blocks user requests asking to recommend competitors (pre_call)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"guardrail_name": "competitor-recommendation-output-filter",
|
||||
"litellm_params": {
|
||||
"guardrail": "litellm_content_filter",
|
||||
"mode": "post_call",
|
||||
"blocked_words": "{{competitor_recommendation_words}}"
|
||||
},
|
||||
"guardrail_info": {
|
||||
"description": "Blocks AI from recommending, suggesting, or directing users to competitor services"
|
||||
"description": "Blocks AI from recommending or suggesting competitor services (post_call)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"guardrail_name": "competitor-comparison-filter",
|
||||
"guardrail_name": "competitor-comparison-input-filter",
|
||||
"litellm_params": {
|
||||
"guardrail": "litellm_content_filter",
|
||||
"mode": "pre_call",
|
||||
"blocked_words": "{{competitor_comparison_words}}"
|
||||
},
|
||||
"guardrail_info": {
|
||||
"description": "Blocks user inputs requesting unfavorable brand comparisons (pre_call)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"guardrail_name": "competitor-comparison-output-filter",
|
||||
"litellm_params": {
|
||||
"guardrail": "litellm_content_filter",
|
||||
"mode": "post_call",
|
||||
"blocked_words": "{{competitor_comparison_words}}"
|
||||
},
|
||||
"guardrail_info": {
|
||||
"description": "Blocks unfavorable comparisons between your brand and competitors in AI outputs"
|
||||
"description": "Blocks AI outputs with unfavorable brand comparisons (post_call)"
|
||||
}
|
||||
}
|
||||
],
|
||||
"templateData": {
|
||||
"policy_name": "competitor-mention-detection",
|
||||
"description": "Detects and blocks competitor mentions in AI outputs. Uses LLM-powered competitor discovery based on your brand name.",
|
||||
"description": "Detects and blocks competitor mentions in both inputs and outputs. Uses LLM-powered competitor discovery based on your brand name.",
|
||||
"guardrails_add": [
|
||||
"competitor-input-blocker",
|
||||
"competitor-output-blocker",
|
||||
"competitor-recommendation-filter",
|
||||
"competitor-comparison-filter"
|
||||
"competitor-recommendation-input-filter",
|
||||
"competitor-recommendation-output-filter",
|
||||
"competitor-comparison-input-filter",
|
||||
"competitor-comparison-output-filter"
|
||||
],
|
||||
"guardrails_remove": []
|
||||
},
|
||||
|
||||
+32
-2
@@ -11,7 +11,11 @@ identifier_words:
|
||||
- "airline"
|
||||
- "carrier"
|
||||
- "airways"
|
||||
- "flight"
|
||||
- "flights"
|
||||
- "Emirates"
|
||||
- "Etihad"
|
||||
- "Qatar Airways"
|
||||
|
||||
additional_block_words:
|
||||
# Fake incident reports
|
||||
@@ -57,6 +61,25 @@ additional_block_words:
|
||||
- "unsafe"
|
||||
- "dangerous"
|
||||
- "negligence"
|
||||
# Competitor recommendations
|
||||
- "consider etihad"
|
||||
- "try qatar"
|
||||
- "switch to"
|
||||
- "better value"
|
||||
- "better product"
|
||||
- "best product"
|
||||
- "caught up"
|
||||
- "competitors have"
|
||||
- "recommend qatar"
|
||||
- "recommend etihad"
|
||||
- "consider switching"
|
||||
# Ops data leakage
|
||||
- "crew roster"
|
||||
- "crew schedule"
|
||||
- "crew list"
|
||||
- "pilot names"
|
||||
- "gate assignment"
|
||||
- "cabin crew"
|
||||
|
||||
always_block_keywords:
|
||||
- keyword: "plane crash"
|
||||
@@ -73,13 +96,20 @@ always_block_keywords:
|
||||
severity: "high"
|
||||
- keyword: "fleet grounded"
|
||||
severity: "high"
|
||||
- keyword: "crew roster"
|
||||
severity: "high"
|
||||
- keyword: "crew schedule"
|
||||
severity: "high"
|
||||
- keyword: "pilot names"
|
||||
severity: "high"
|
||||
- keyword: "gate assignment"
|
||||
severity: "high"
|
||||
|
||||
exceptions:
|
||||
- "how safe is"
|
||||
- "safety record"
|
||||
- "safety rating"
|
||||
- "what is"
|
||||
- "explain"
|
||||
- "what is the baggage"
|
||||
- "historical"
|
||||
- "aviation history"
|
||||
- "customer review"
|
||||
|
||||
+5
-3
@@ -51,6 +51,11 @@ additional_block_words:
|
||||
- "break into cockpit"
|
||||
- "cockpit intrusion"
|
||||
- "cockpit breach"
|
||||
- "locking mechanism"
|
||||
- "open the cockpit"
|
||||
- "cockpit door open"
|
||||
- "enter the cockpit"
|
||||
- "access the cockpit"
|
||||
# Aircraft vulnerabilities
|
||||
- "vulnerability"
|
||||
- "exploit"
|
||||
@@ -115,6 +120,3 @@ exceptions:
|
||||
- "how to report"
|
||||
- "incident report"
|
||||
- "safety culture"
|
||||
- "what is"
|
||||
- "explain"
|
||||
- "research on"
|
||||
|
||||
@@ -9,14 +9,29 @@ All /policy management endpoints
|
||||
/policy/templates - Get policy templates (GitHub with local fallback)
|
||||
"""
|
||||
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
from typing import TYPE_CHECKING, List, Literal, Optional, TypedDict, cast
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
AsyncIterator,
|
||||
List,
|
||||
Literal,
|
||||
Optional,
|
||||
TypedDict,
|
||||
cast,
|
||||
)
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
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
|
||||
@@ -511,6 +526,48 @@ async def get_policy_templates(
|
||||
class EnrichTemplateRequest(BaseModel):
|
||||
template_id: str
|
||||
parameters: dict
|
||||
model: Optional[str] = None
|
||||
competitors: Optional[List[str]] = Field(
|
||||
default=None,
|
||||
max_length=MAX_COMPETITOR_NAMES,
|
||||
description="Optional list of competitor names",
|
||||
)
|
||||
instruction: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Refinement instruction for modifying the competitor list (e.g. 'add 10 more from Asia')",
|
||||
)
|
||||
|
||||
|
||||
def _validate_enrichment_request(data: EnrichTemplateRequest) -> tuple[dict, dict, str]:
|
||||
"""
|
||||
Validate enrichment request and return (template, llm_enrichment, brand_name).
|
||||
|
||||
Raises HTTPException on validation failure.
|
||||
"""
|
||||
templates = _load_policy_templates_from_local_backup()
|
||||
template = next((t for t in templates if t.get("id") == data.template_id), None)
|
||||
if template is None:
|
||||
raise HTTPException(status_code=404, detail=f"Template '{data.template_id}' not found")
|
||||
|
||||
llm_enrichment = template.get("llm_enrichment")
|
||||
if llm_enrichment is None:
|
||||
raise HTTPException(status_code=400, detail="Template does not support LLM enrichment")
|
||||
|
||||
# Validate competitors list size if provided
|
||||
if data.competitors and len(data.competitors) > MAX_COMPETITOR_NAMES:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"competitors list exceeds maximum of {MAX_COMPETITOR_NAMES}",
|
||||
)
|
||||
|
||||
brand_name = data.parameters.get(llm_enrichment["parameter"], "")
|
||||
if not brand_name:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Parameter '{llm_enrichment['parameter']}' is required",
|
||||
)
|
||||
|
||||
return template, llm_enrichment, brand_name
|
||||
|
||||
|
||||
@router.post(
|
||||
@@ -530,108 +587,311 @@ async def enrich_policy_template(
|
||||
Calls an onboarded LLM to discover competitors for the given brand name,
|
||||
then returns enriched guardrailDefinitions with the discovered data populated.
|
||||
"""
|
||||
templates = _load_policy_templates_from_local_backup()
|
||||
template = next((t for t in templates if t.get("id") == data.template_id), None)
|
||||
if template is None:
|
||||
raise HTTPException(status_code=404, detail=f"Template '{data.template_id}' not found")
|
||||
template, llm_enrichment, brand_name = _validate_enrichment_request(data)
|
||||
model = data.model or DEFAULT_COMPETITOR_DISCOVERY_MODEL
|
||||
|
||||
llm_enrichment = template.get("llm_enrichment")
|
||||
if llm_enrichment is None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Template does not support LLM enrichment",
|
||||
if data.competitors:
|
||||
competitors = data.competitors
|
||||
else:
|
||||
prompt = llm_enrichment["prompt"].replace(
|
||||
"{{" + llm_enrichment["parameter"] + "}}", brand_name
|
||||
)
|
||||
competitors = await _discover_competitors_via_llm(prompt, model=model)
|
||||
|
||||
brand_name = data.parameters.get(llm_enrichment["parameter"], "")
|
||||
if not brand_name:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Parameter '{llm_enrichment['parameter']}' is required",
|
||||
)
|
||||
|
||||
prompt = llm_enrichment["prompt"].replace(
|
||||
"{{" + llm_enrichment["parameter"] + "}}", brand_name
|
||||
)
|
||||
|
||||
competitors = await _discover_competitors_via_llm(prompt)
|
||||
variations_map = await _generate_competitor_variations(competitors, model=model)
|
||||
|
||||
enriched_definitions = _build_competitor_guardrail_definitions(
|
||||
template.get("guardrailDefinitions", []),
|
||||
competitors,
|
||||
brand_name,
|
||||
variations_map,
|
||||
)
|
||||
|
||||
return {"guardrailDefinitions": enriched_definitions, "competitors": competitors}
|
||||
return {
|
||||
"guardrailDefinitions": enriched_definitions,
|
||||
"competitors": competitors,
|
||||
"competitor_variations": variations_map,
|
||||
}
|
||||
|
||||
|
||||
async def _discover_competitors_via_llm(prompt: str) -> list:
|
||||
"""Call an onboarded LLM to discover competitor names."""
|
||||
import litellm
|
||||
def _build_refinement_prompt(
|
||||
instruction: str,
|
||||
existing_competitors: list[str],
|
||||
brand_name: str,
|
||||
) -> str:
|
||||
"""Build a prompt for refining the competitor list based on user instruction."""
|
||||
existing_list = ", ".join(existing_competitors)
|
||||
return (
|
||||
f"I have a brand called '{brand_name}' and the following competitor list:\n"
|
||||
f"{existing_list}\n\n"
|
||||
f"User instruction: {instruction}\n\n"
|
||||
"Return ONLY the NEW names to add (not the existing ones), one per line, "
|
||||
"no numbering, no explanations. If the instruction asks to remove names, "
|
||||
"return nothing."
|
||||
)
|
||||
|
||||
|
||||
async def _stream_llm_competitor_names(
|
||||
prompt: str,
|
||||
model: str,
|
||||
existing: list[str],
|
||||
) -> AsyncIterator[tuple[Optional[str], bool]]:
|
||||
"""
|
||||
Stream competitor names from LLM. Yields (name, is_error) tuples.
|
||||
|
||||
Deduplicates against existing names (case-insensitive).
|
||||
"""
|
||||
from litellm.proxy.proxy_server import llm_router
|
||||
|
||||
if llm_router is None:
|
||||
raise ValueError("LLM router not initialized")
|
||||
|
||||
existing_lower = {n.lower() for n in existing}
|
||||
response = await llm_router.acompletion(
|
||||
model=model,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
temperature=COMPETITOR_LLM_TEMPERATURE,
|
||||
stream=True,
|
||||
)
|
||||
buffer = ""
|
||||
count = len(existing)
|
||||
async for chunk in response: # type: ignore[union-attr]
|
||||
delta = chunk.choices[0].delta.content or ""
|
||||
buffer += delta
|
||||
while "\n" in buffer:
|
||||
line, buffer = buffer.split("\n", 1)
|
||||
name = _clean_competitor_line(line)
|
||||
if name and name.lower() not in existing_lower and count < MAX_COMPETITOR_NAMES:
|
||||
existing_lower.add(name.lower())
|
||||
count += 1
|
||||
yield name, False
|
||||
# Handle remaining buffer
|
||||
name = _clean_competitor_line(buffer)
|
||||
if name and name.lower() not in existing_lower and count < MAX_COMPETITOR_NAMES:
|
||||
yield name, False
|
||||
|
||||
|
||||
async def _stream_competitor_events(
|
||||
data: EnrichTemplateRequest,
|
||||
template: dict,
|
||||
llm_enrichment: dict,
|
||||
brand_name: str,
|
||||
model: str,
|
||||
) -> AsyncIterator[str]:
|
||||
"""Stream competitor names as SSE events, then emit a final 'done' event."""
|
||||
competitors: list[str] = list(data.competitors or [])
|
||||
|
||||
if data.instruction and competitors:
|
||||
# Refinement mode: keep existing, stream only new names
|
||||
for comp in competitors:
|
||||
yield f"data: {json.dumps({'type': 'competitor', 'name': comp})}\n\n"
|
||||
|
||||
refinement_prompt = _build_refinement_prompt(
|
||||
data.instruction, competitors, brand_name
|
||||
)
|
||||
try:
|
||||
async for name, _ in _stream_llm_competitor_names(
|
||||
refinement_prompt, model, competitors
|
||||
):
|
||||
if name:
|
||||
competitors.append(name)
|
||||
yield f"data: {json.dumps({'type': 'competitor', 'name': name})}\n\n"
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.error("LLM competitor refinement failed: %s", e)
|
||||
yield f"data: {json.dumps({'type': 'error', 'message': str(e)})}\n\n"
|
||||
return
|
||||
elif data.competitors and not data.instruction:
|
||||
# Free-form mode (no instruction): just emit existing
|
||||
for comp in competitors:
|
||||
yield f"data: {json.dumps({'type': 'competitor', 'name': comp})}\n\n"
|
||||
else:
|
||||
# Initial discovery mode
|
||||
prompt = llm_enrichment["prompt"].replace(
|
||||
"{{" + llm_enrichment["parameter"] + "}}", brand_name
|
||||
)
|
||||
try:
|
||||
async for name, _ in _stream_llm_competitor_names(
|
||||
prompt, model, []
|
||||
):
|
||||
if name:
|
||||
competitors.append(name)
|
||||
yield f"data: {json.dumps({'type': 'competitor', 'name': name})}\n\n"
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.error("LLM competitor streaming failed: %s", e)
|
||||
yield f"data: {json.dumps({'type': 'error', 'message': str(e)})}\n\n"
|
||||
return
|
||||
|
||||
yield f"data: {json.dumps({'type': 'status', 'message': f'Generating alternate spellings for {len(competitors)} competitors...'})}\n\n"
|
||||
variations_map = await _generate_competitor_variations(competitors, model=model)
|
||||
|
||||
total_variations = sum(len(v) for v in variations_map.values())
|
||||
yield f"data: {json.dumps({'type': 'status', 'message': f'Building guardrail definitions with {total_variations} variations...'})}\n\n"
|
||||
enriched_definitions = _build_competitor_guardrail_definitions(
|
||||
template.get("guardrailDefinitions", []),
|
||||
competitors,
|
||||
brand_name,
|
||||
variations_map,
|
||||
)
|
||||
|
||||
yield f"data: {json.dumps({'type': 'done', 'competitors': competitors, 'competitor_variations': variations_map, 'guardrailDefinitions': enriched_definitions})}\n\n"
|
||||
|
||||
|
||||
@router.post(
|
||||
"/policy/templates/enrich/stream",
|
||||
tags=["policy management"],
|
||||
dependencies=[Depends(user_api_key_auth)],
|
||||
)
|
||||
async def enrich_policy_template_stream(
|
||||
data: EnrichTemplateRequest,
|
||||
request: Request,
|
||||
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
|
||||
):
|
||||
"""
|
||||
Stream competitor names as SSE events as the LLM generates them.
|
||||
|
||||
Events:
|
||||
- data: {"type": "competitor", "name": "..."} — each competitor as discovered
|
||||
- data: {"type": "done", "competitors": [...], "competitor_variations": {...}, "guardrailDefinitions": [...]}
|
||||
"""
|
||||
template, llm_enrichment, brand_name = _validate_enrichment_request(data)
|
||||
model = data.model or DEFAULT_COMPETITOR_DISCOVERY_MODEL
|
||||
|
||||
return StreamingResponse(
|
||||
_stream_competitor_events(data, template, llm_enrichment, brand_name, model),
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
|
||||
|
||||
def _clean_competitor_line(line: str) -> Optional[str]:
|
||||
"""Strip numbering, bullets, and whitespace from a competitor name line."""
|
||||
name = line.strip().strip(".-) ").strip()
|
||||
return name if name and len(name) > 1 else None
|
||||
|
||||
|
||||
async def _generate_competitor_variations(
|
||||
competitors: list, model: str = DEFAULT_COMPETITOR_DISCOVERY_MODEL
|
||||
) -> dict:
|
||||
"""Generate common misspellings, abbreviations, and alternate names for each competitor."""
|
||||
if not competitors:
|
||||
return {}
|
||||
|
||||
# Cap the list to prevent oversized prompts
|
||||
capped = competitors[:MAX_COMPETITOR_NAMES]
|
||||
names_list = "\n".join(capped)
|
||||
prompt = (
|
||||
"For each company/brand name below, list 3-5 common misspellings, abbreviations, "
|
||||
"and alternate names that people might type. Include typos, missing spaces, "
|
||||
"wrong suffixes (e.g. 'Airlines' vs 'Airways' vs 'Airline'), and common shortcuts.\n\n"
|
||||
f"Names:\n{names_list}\n\n"
|
||||
"Return the result as one line per variation in the format:\n"
|
||||
"OriginalName: variation1, variation2, variation3\n"
|
||||
"Use the EXACT original name before the colon. No numbering, no extra text."
|
||||
)
|
||||
|
||||
try:
|
||||
response = await litellm.acompletion(
|
||||
model="gpt-4o-mini",
|
||||
from litellm.proxy.proxy_server import llm_router
|
||||
|
||||
if llm_router is None:
|
||||
raise ValueError("LLM router not initialized")
|
||||
response = await llm_router.acompletion(
|
||||
model=model,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
temperature=0.3,
|
||||
temperature=COMPETITOR_LLM_TEMPERATURE,
|
||||
)
|
||||
raw = response.choices[0].message.content or "" # type: ignore
|
||||
return _parse_variations_response(raw, capped)
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.error("LLM competitor variation generation failed: %s", e)
|
||||
return {}
|
||||
|
||||
|
||||
def _parse_variations_response(raw: str, competitors: list) -> dict[str, list[str]]:
|
||||
"""Parse the LLM response for competitor variations into a name -> variations map."""
|
||||
# Build a lowercase lookup for case-insensitive matching
|
||||
lower_to_canonical = {comp.lower(): comp for comp in competitors}
|
||||
variations_map: dict[str, list[str]] = {}
|
||||
|
||||
for line in raw.strip().split("\n"):
|
||||
if ":" not in line:
|
||||
continue
|
||||
name, _, variations_str = line.partition(":")
|
||||
canonical = lower_to_canonical.get(name.strip().lower())
|
||||
if canonical is None:
|
||||
continue
|
||||
variations = [
|
||||
v.strip()
|
||||
for v in variations_str.split(",")
|
||||
if v.strip() and v.strip().lower() != canonical.lower()
|
||||
]
|
||||
variations_map[canonical] = variations
|
||||
|
||||
return variations_map
|
||||
|
||||
|
||||
async def _discover_competitors_via_llm(
|
||||
prompt: str, model: str = DEFAULT_COMPETITOR_DISCOVERY_MODEL
|
||||
) -> list:
|
||||
"""Call an onboarded LLM to discover competitor names."""
|
||||
try:
|
||||
from litellm.proxy.proxy_server import llm_router
|
||||
|
||||
if llm_router is None:
|
||||
raise ValueError("LLM router not initialized")
|
||||
response = await llm_router.acompletion(
|
||||
model=model,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
temperature=COMPETITOR_LLM_TEMPERATURE,
|
||||
)
|
||||
raw = response.choices[0].message.content or "" # type: ignore
|
||||
competitors = [
|
||||
line.strip().strip(".-) ").strip()
|
||||
name
|
||||
for line in raw.strip().split("\n")
|
||||
if line.strip() and len(line.strip()) > 1
|
||||
if (name := _clean_competitor_line(line)) is not None
|
||||
]
|
||||
return competitors[:15]
|
||||
return competitors[:MAX_COMPETITOR_NAMES]
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.error("LLM competitor discovery failed: %s", e)
|
||||
return []
|
||||
|
||||
|
||||
def _build_all_names_per_competitor(
|
||||
competitors: list[str], variations_map: dict[str, list[str]]
|
||||
) -> dict[str, list[str]]:
|
||||
"""Build canonical + variation name lists for each competitor."""
|
||||
return {
|
||||
comp: [comp] + variations_map.get(comp, [])
|
||||
for comp in competitors
|
||||
}
|
||||
|
||||
|
||||
def _build_competitor_guardrail_definitions(
|
||||
definitions: list,
|
||||
competitors: list,
|
||||
brand_name: str,
|
||||
variations_map: Optional[dict] = None,
|
||||
) -> list:
|
||||
"""Build enriched guardrailDefinitions with competitor names populated."""
|
||||
import copy
|
||||
|
||||
"""Build enriched guardrailDefinitions with competitor names and variations populated."""
|
||||
variations_map = variations_map or {}
|
||||
enriched = copy.deepcopy(definitions)
|
||||
all_names = _build_all_names_per_competitor(competitors, variations_map)
|
||||
|
||||
output_blocked = [
|
||||
{"keyword": comp, "action": "BLOCK", "description": f"Competitor: {comp}"}
|
||||
for comp in competitors
|
||||
]
|
||||
|
||||
recommendation_blocked = []
|
||||
for comp in competitors:
|
||||
recommendation_blocked.append(
|
||||
{"keyword": f"try {comp}", "action": "BLOCK", "description": "Recommendation to competitor"}
|
||||
)
|
||||
recommendation_blocked.append(
|
||||
{"keyword": f"use {comp}", "action": "BLOCK", "description": "Recommendation to competitor"}
|
||||
)
|
||||
recommendation_blocked.append(
|
||||
{"keyword": f"switch to {comp}", "action": "BLOCK", "description": "Recommendation to competitor"}
|
||||
)
|
||||
recommendation_blocked.append(
|
||||
{"keyword": f"consider {comp}", "action": "BLOCK", "description": "Recommendation to competitor"}
|
||||
)
|
||||
|
||||
comparison_blocked = []
|
||||
for comp in competitors:
|
||||
comparison_blocked.append(
|
||||
{"keyword": f"{comp} is better", "action": "BLOCK", "description": "Unfavorable comparison"}
|
||||
)
|
||||
comparison_blocked.append(
|
||||
{"keyword": f"better than {brand_name}", "action": "BLOCK", "description": "Unfavorable comparison"}
|
||||
)
|
||||
comparison_blocked.append(
|
||||
{"keyword": f"{brand_name} is worse", "action": "BLOCK", "description": "Unfavorable comparison"}
|
||||
)
|
||||
output_blocked = _build_name_blocked_words(competitors, all_names)
|
||||
recommendation_blocked = _build_recommendation_blocked_words(competitors, all_names)
|
||||
comparison_blocked = _build_comparison_blocked_words(competitors, all_names, brand_name)
|
||||
|
||||
blocked_words_map = {
|
||||
"competitor-output-blocker": output_blocked,
|
||||
"competitor-input-blocker": output_blocked,
|
||||
"competitor-name-blocker": output_blocked,
|
||||
"competitor-name-input-blocker": output_blocked,
|
||||
"competitor-name-output-blocker": output_blocked,
|
||||
"competitor-recommendation-filter": recommendation_blocked,
|
||||
"competitor-recommendation-input-filter": recommendation_blocked,
|
||||
"competitor-recommendation-output-filter": recommendation_blocked,
|
||||
"competitor-comparison-filter": comparison_blocked,
|
||||
"competitor-comparison-input-filter": comparison_blocked,
|
||||
"competitor-comparison-output-filter": comparison_blocked,
|
||||
}
|
||||
|
||||
for defn in enriched:
|
||||
@@ -640,3 +900,59 @@ def _build_competitor_guardrail_definitions(
|
||||
defn["litellm_params"]["blocked_words"] = blocked_words_map[guardrail_name]
|
||||
|
||||
return enriched
|
||||
|
||||
|
||||
def _build_name_blocked_words(
|
||||
competitors: list[str], all_names: dict[str, list[str]]
|
||||
) -> list[dict]:
|
||||
"""Build blocked word entries for direct competitor name mentions."""
|
||||
result = []
|
||||
for comp in competitors:
|
||||
for name in all_names[comp]:
|
||||
desc = f"Competitor: {comp}" if name == comp else f"Competitor variation ({comp}): {name}"
|
||||
result.append({"keyword": name, "action": "BLOCK", "description": desc})
|
||||
return result
|
||||
|
||||
|
||||
def _build_recommendation_blocked_words(
|
||||
competitors: list[str], all_names: dict[str, list[str]]
|
||||
) -> list[dict]:
|
||||
"""Build blocked word entries for competitor recommendations."""
|
||||
result = []
|
||||
for comp in competitors:
|
||||
for name in all_names[comp]:
|
||||
for prefix in ["try", "use", "switch to", "consider"]:
|
||||
result.append({
|
||||
"keyword": f"{prefix} {name}",
|
||||
"action": "BLOCK",
|
||||
"description": f"Recommendation to competitor ({comp})",
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
def _build_comparison_blocked_words(
|
||||
competitors: list[str], all_names: dict[str, list[str]], brand_name: str
|
||||
) -> list[dict]:
|
||||
"""Build blocked word entries for unfavorable competitor comparisons."""
|
||||
result = []
|
||||
for comp in competitors:
|
||||
for name in all_names[comp]:
|
||||
result.append({
|
||||
"keyword": f"{name} is better",
|
||||
"action": "BLOCK",
|
||||
"description": f"Unfavorable comparison ({comp})",
|
||||
})
|
||||
|
||||
# Brand-level comparisons (only need one entry each, not per-competitor)
|
||||
result.append({
|
||||
"keyword": f"better than {brand_name}",
|
||||
"action": "BLOCK",
|
||||
"description": "Unfavorable comparison",
|
||||
})
|
||||
result.append({
|
||||
"keyword": f"{brand_name} is worse",
|
||||
"action": "BLOCK",
|
||||
"description": "Unfavorable comparison",
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
+137
-15
@@ -1522,7 +1522,13 @@
|
||||
"guardrails": [
|
||||
"aviation-ops-data-protection",
|
||||
"aviation-safety-topic-filter",
|
||||
"airline-brand-protection-filter"
|
||||
"airline-brand-protection-filter",
|
||||
"competitor-name-input-blocker",
|
||||
"competitor-name-output-blocker",
|
||||
"competitor-recommendation-input-filter",
|
||||
"competitor-recommendation-output-filter",
|
||||
"competitor-comparison-input-filter",
|
||||
"competitor-comparison-output-filter"
|
||||
],
|
||||
"complexity": "High",
|
||||
"parameters": [
|
||||
@@ -1531,9 +1537,14 @@
|
||||
"label": "Your Airline / Brand Name",
|
||||
"type": "text",
|
||||
"required": true,
|
||||
"placeholder": "e.g. Emirates"
|
||||
"placeholder": "e.g. Acme Airlines"
|
||||
}
|
||||
],
|
||||
"llm_enrichment": {
|
||||
"parameter": "brand_name",
|
||||
"prompt": "List the top 30 direct competitors of {{brand_name}} in the airline industry. Include major international carriers, regional competitors, and low-cost carriers that operate on overlapping routes. Return ONLY airline/brand names, one per line, no numbering, no explanations.",
|
||||
"result_key": "competitors"
|
||||
},
|
||||
"guardrailDefinitions": [
|
||||
{
|
||||
"guardrail_name": "aviation-ops-data-protection",
|
||||
@@ -1675,6 +1686,72 @@
|
||||
"guardrail_info": {
|
||||
"description": "Blocks AI-generated fake incident reports, unauthorized statements, and reputation-damaging content about your brand (runs on output)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"guardrail_name": "competitor-name-input-blocker",
|
||||
"litellm_params": {
|
||||
"guardrail": "litellm_content_filter",
|
||||
"mode": "pre_call",
|
||||
"blocked_words": "{{competitors_blocked_words}}"
|
||||
},
|
||||
"guardrail_info": {
|
||||
"description": "Blocks user inputs that mention competitor names (pre_call)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"guardrail_name": "competitor-name-output-blocker",
|
||||
"litellm_params": {
|
||||
"guardrail": "litellm_content_filter",
|
||||
"mode": "post_call",
|
||||
"blocked_words": "{{competitors_blocked_words}}"
|
||||
},
|
||||
"guardrail_info": {
|
||||
"description": "Blocks AI outputs that mention competitor names (post_call)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"guardrail_name": "competitor-recommendation-input-filter",
|
||||
"litellm_params": {
|
||||
"guardrail": "litellm_content_filter",
|
||||
"mode": "pre_call",
|
||||
"blocked_words": "{{competitor_recommendation_words}}"
|
||||
},
|
||||
"guardrail_info": {
|
||||
"description": "Blocks user requests asking to recommend competitors (pre_call)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"guardrail_name": "competitor-recommendation-output-filter",
|
||||
"litellm_params": {
|
||||
"guardrail": "litellm_content_filter",
|
||||
"mode": "post_call",
|
||||
"blocked_words": "{{competitor_recommendation_words}}"
|
||||
},
|
||||
"guardrail_info": {
|
||||
"description": "Blocks AI from recommending or suggesting competitor services (post_call)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"guardrail_name": "competitor-comparison-input-filter",
|
||||
"litellm_params": {
|
||||
"guardrail": "litellm_content_filter",
|
||||
"mode": "pre_call",
|
||||
"blocked_words": "{{competitor_comparison_words}}"
|
||||
},
|
||||
"guardrail_info": {
|
||||
"description": "Blocks user inputs requesting unfavorable brand comparisons (pre_call)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"guardrail_name": "competitor-comparison-output-filter",
|
||||
"litellm_params": {
|
||||
"guardrail": "litellm_content_filter",
|
||||
"mode": "post_call",
|
||||
"blocked_words": "{{competitor_comparison_words}}"
|
||||
},
|
||||
"guardrail_info": {
|
||||
"description": "Blocks AI outputs with unfavorable brand comparisons (post_call)"
|
||||
}
|
||||
}
|
||||
],
|
||||
"templateData": {
|
||||
@@ -1683,7 +1760,13 @@
|
||||
"guardrails_add": [
|
||||
"aviation-ops-data-protection",
|
||||
"aviation-safety-topic-filter",
|
||||
"airline-brand-protection-filter"
|
||||
"airline-brand-protection-filter",
|
||||
"competitor-name-input-blocker",
|
||||
"competitor-name-output-blocker",
|
||||
"competitor-recommendation-input-filter",
|
||||
"competitor-recommendation-output-filter",
|
||||
"competitor-comparison-input-filter",
|
||||
"competitor-comparison-output-filter"
|
||||
],
|
||||
"guardrails_remove": []
|
||||
},
|
||||
@@ -1812,9 +1895,12 @@
|
||||
"iconColor": "text-orange-500",
|
||||
"iconBg": "bg-orange-50",
|
||||
"guardrails": [
|
||||
"competitor-input-blocker",
|
||||
"competitor-output-blocker",
|
||||
"competitor-recommendation-filter",
|
||||
"competitor-comparison-filter"
|
||||
"competitor-recommendation-input-filter",
|
||||
"competitor-recommendation-output-filter",
|
||||
"competitor-comparison-input-filter",
|
||||
"competitor-comparison-output-filter"
|
||||
],
|
||||
"complexity": "Medium",
|
||||
"parameters": [
|
||||
@@ -1823,15 +1909,26 @@
|
||||
"label": "Your Brand Name",
|
||||
"type": "text",
|
||||
"required": true,
|
||||
"placeholder": "e.g. Emirates"
|
||||
"placeholder": "e.g. Acme Airlines"
|
||||
}
|
||||
],
|
||||
"llm_enrichment": {
|
||||
"parameter": "brand_name",
|
||||
"prompt": "List the top 10 direct competitors of {{brand_name}} in the same industry. Return ONLY company/brand names, one per line, no numbering, no explanations.",
|
||||
"prompt": "List the top 30 direct competitors of {{brand_name}} in the same industry. Return ONLY company/brand names, one per line, no numbering, no explanations.",
|
||||
"result_key": "competitors"
|
||||
},
|
||||
"guardrailDefinitions": [
|
||||
{
|
||||
"guardrail_name": "competitor-input-blocker",
|
||||
"litellm_params": {
|
||||
"guardrail": "litellm_content_filter",
|
||||
"mode": "pre_call",
|
||||
"blocked_words": "{{competitors_blocked_words}}"
|
||||
},
|
||||
"guardrail_info": {
|
||||
"description": "Blocks user inputs that mention competitor brands (pre_call)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"guardrail_name": "competitor-output-blocker",
|
||||
"litellm_params": {
|
||||
@@ -1840,39 +1937,64 @@
|
||||
"blocked_words": "{{competitors_blocked_words}}"
|
||||
},
|
||||
"guardrail_info": {
|
||||
"description": "Blocks AI outputs that mention or promote competitor brands (auto-discovered via LLM)"
|
||||
"description": "Blocks AI outputs that mention competitor brands (post_call)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"guardrail_name": "competitor-recommendation-filter",
|
||||
"guardrail_name": "competitor-recommendation-input-filter",
|
||||
"litellm_params": {
|
||||
"guardrail": "litellm_content_filter",
|
||||
"mode": "pre_call",
|
||||
"blocked_words": "{{competitor_recommendation_words}}"
|
||||
},
|
||||
"guardrail_info": {
|
||||
"description": "Blocks user requests asking to recommend competitors (pre_call)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"guardrail_name": "competitor-recommendation-output-filter",
|
||||
"litellm_params": {
|
||||
"guardrail": "litellm_content_filter",
|
||||
"mode": "post_call",
|
||||
"blocked_words": "{{competitor_recommendation_words}}"
|
||||
},
|
||||
"guardrail_info": {
|
||||
"description": "Blocks AI from recommending, suggesting, or directing users to competitor services"
|
||||
"description": "Blocks AI from recommending or suggesting competitor services (post_call)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"guardrail_name": "competitor-comparison-filter",
|
||||
"guardrail_name": "competitor-comparison-input-filter",
|
||||
"litellm_params": {
|
||||
"guardrail": "litellm_content_filter",
|
||||
"mode": "pre_call",
|
||||
"blocked_words": "{{competitor_comparison_words}}"
|
||||
},
|
||||
"guardrail_info": {
|
||||
"description": "Blocks user inputs requesting unfavorable brand comparisons (pre_call)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"guardrail_name": "competitor-comparison-output-filter",
|
||||
"litellm_params": {
|
||||
"guardrail": "litellm_content_filter",
|
||||
"mode": "post_call",
|
||||
"blocked_words": "{{competitor_comparison_words}}"
|
||||
},
|
||||
"guardrail_info": {
|
||||
"description": "Blocks unfavorable comparisons between your brand and competitors in AI outputs"
|
||||
"description": "Blocks AI outputs with unfavorable brand comparisons (post_call)"
|
||||
}
|
||||
}
|
||||
],
|
||||
"templateData": {
|
||||
"policy_name": "competitor-mention-detection",
|
||||
"description": "Detects and blocks competitor mentions in AI outputs. Uses LLM-powered competitor discovery based on your brand name.",
|
||||
"description": "Detects and blocks competitor mentions in both inputs and outputs. Uses LLM-powered competitor discovery based on your brand name.",
|
||||
"guardrails_add": [
|
||||
"competitor-input-blocker",
|
||||
"competitor-output-blocker",
|
||||
"competitor-recommendation-filter",
|
||||
"competitor-comparison-filter"
|
||||
"competitor-recommendation-input-filter",
|
||||
"competitor-recommendation-output-filter",
|
||||
"competitor-comparison-input-filter",
|
||||
"competitor-comparison-output-filter"
|
||||
],
|
||||
"guardrails_remove": []
|
||||
},
|
||||
|
||||
@@ -587,3 +587,256 @@ class TestApplyPoliciesDirectGuardrailNames:
|
||||
# Sorted order: direct_guardrail then from_policy; final output is from_policy
|
||||
assert result["inputs"] == first_output
|
||||
assert result["guardrail_errors"] == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests for competitor enrichment helper functions
|
||||
# ---------------------------------------------------------------------------
|
||||
from litellm.proxy.management_endpoints.policy_endpoints import (
|
||||
_build_all_names_per_competitor,
|
||||
_build_comparison_blocked_words,
|
||||
_build_competitor_guardrail_definitions,
|
||||
_build_name_blocked_words,
|
||||
_build_recommendation_blocked_words,
|
||||
_build_refinement_prompt,
|
||||
_clean_competitor_line,
|
||||
_parse_variations_response,
|
||||
)
|
||||
|
||||
|
||||
class TestCleanCompetitorLine:
|
||||
"""Tests for _clean_competitor_line."""
|
||||
|
||||
def test_strips_bullets_and_dashes(self):
|
||||
assert _clean_competitor_line("- United Airlines") == "United Airlines"
|
||||
assert _clean_competitor_line(" - JetBlue ") == "JetBlue"
|
||||
|
||||
def test_strips_trailing_punctuation(self):
|
||||
assert _clean_competitor_line("Delta Airlines.") == "Delta Airlines"
|
||||
assert _clean_competitor_line("Southwest)") == "Southwest"
|
||||
|
||||
def test_returns_none_for_empty(self):
|
||||
assert _clean_competitor_line("") is None
|
||||
assert _clean_competitor_line(" ") is None
|
||||
|
||||
def test_returns_none_for_single_char(self):
|
||||
assert _clean_competitor_line("A") is None
|
||||
assert _clean_competitor_line(" - ") is None
|
||||
|
||||
def test_plain_name(self):
|
||||
assert _clean_competitor_line("Qatar Airways") == "Qatar Airways"
|
||||
|
||||
|
||||
class TestParseVariationsResponse:
|
||||
"""Tests for _parse_variations_response."""
|
||||
|
||||
def test_parses_standard_format(self):
|
||||
raw = "Delta Airlines: Delta Air Lines, DeltaAirlines, Delta\nUnited Airlines: United, UAL"
|
||||
competitors = ["Delta Airlines", "United Airlines"]
|
||||
result = _parse_variations_response(raw, competitors)
|
||||
assert "Delta Airlines" in result
|
||||
assert "Delta Air Lines" in result["Delta Airlines"]
|
||||
assert "United" in result["United Airlines"]
|
||||
|
||||
def test_case_insensitive_matching(self):
|
||||
raw = "delta airlines: Delta Air Lines, DeltaAirlines"
|
||||
competitors = ["Delta Airlines"]
|
||||
result = _parse_variations_response(raw, competitors)
|
||||
assert "Delta Airlines" in result
|
||||
assert len(result["Delta Airlines"]) == 2
|
||||
|
||||
def test_skips_lines_without_colon(self):
|
||||
raw = "This is a header\nDelta Airlines: Delta Air Lines"
|
||||
competitors = ["Delta Airlines"]
|
||||
result = _parse_variations_response(raw, competitors)
|
||||
assert len(result) == 1
|
||||
|
||||
def test_skips_unknown_competitors(self):
|
||||
raw = "Unknown Corp: Foo, Bar\nDelta Airlines: Delta"
|
||||
competitors = ["Delta Airlines"]
|
||||
result = _parse_variations_response(raw, competitors)
|
||||
assert "Unknown Corp" not in result
|
||||
assert "Delta Airlines" in result
|
||||
|
||||
def test_filters_out_self_reference(self):
|
||||
raw = "Delta Airlines: Delta Airlines, Delta Air Lines"
|
||||
competitors = ["Delta Airlines"]
|
||||
result = _parse_variations_response(raw, competitors)
|
||||
# "Delta Airlines" should be filtered out (same as canonical)
|
||||
assert "Delta Airlines" not in result["Delta Airlines"]
|
||||
assert "Delta Air Lines" in result["Delta Airlines"]
|
||||
|
||||
def test_empty_input(self):
|
||||
assert _parse_variations_response("", []) == {}
|
||||
|
||||
|
||||
class TestBuildRefinementPrompt:
|
||||
"""Tests for _build_refinement_prompt."""
|
||||
|
||||
def test_includes_brand_name(self):
|
||||
prompt = _build_refinement_prompt("add 10 more", ["Delta"], "Emirates")
|
||||
assert "Emirates" in prompt
|
||||
|
||||
def test_includes_existing_competitors(self):
|
||||
prompt = _build_refinement_prompt("add more", ["Delta", "United"], "Emirates")
|
||||
assert "Delta" in prompt
|
||||
assert "United" in prompt
|
||||
|
||||
def test_includes_instruction(self):
|
||||
prompt = _build_refinement_prompt("add 10 from Asia", ["Delta"], "Emirates")
|
||||
assert "add 10 from Asia" in prompt
|
||||
|
||||
def test_asks_for_new_names_only(self):
|
||||
prompt = _build_refinement_prompt("add more", ["Delta"], "Emirates")
|
||||
assert "NEW" in prompt
|
||||
|
||||
|
||||
class TestBuildAllNamesPerCompetitor:
|
||||
"""Tests for _build_all_names_per_competitor."""
|
||||
|
||||
def test_includes_canonical_and_variations(self):
|
||||
result = _build_all_names_per_competitor(
|
||||
["Delta Airlines"], {"Delta Airlines": ["Delta", "DeltaAir"]}
|
||||
)
|
||||
assert result["Delta Airlines"] == ["Delta Airlines", "Delta", "DeltaAir"]
|
||||
|
||||
def test_no_variations(self):
|
||||
result = _build_all_names_per_competitor(["Delta Airlines"], {})
|
||||
assert result["Delta Airlines"] == ["Delta Airlines"]
|
||||
|
||||
def test_multiple_competitors(self):
|
||||
result = _build_all_names_per_competitor(
|
||||
["Delta", "United"],
|
||||
{"Delta": ["DL"], "United": ["UA"]},
|
||||
)
|
||||
assert len(result) == 2
|
||||
assert result["Delta"] == ["Delta", "DL"]
|
||||
assert result["United"] == ["United", "UA"]
|
||||
|
||||
|
||||
class TestBuildNameBlockedWords:
|
||||
"""Tests for _build_name_blocked_words."""
|
||||
|
||||
def test_basic_output(self):
|
||||
all_names = {"Delta": ["Delta", "DL"]}
|
||||
result = _build_name_blocked_words(["Delta"], all_names)
|
||||
keywords = [r["keyword"] for r in result]
|
||||
assert "Delta" in keywords
|
||||
assert "DL" in keywords
|
||||
assert all(r["action"] == "BLOCK" for r in result)
|
||||
|
||||
def test_descriptions_differ_for_variations(self):
|
||||
all_names = {"Delta": ["Delta", "DL"]}
|
||||
result = _build_name_blocked_words(["Delta"], all_names)
|
||||
descs = {r["keyword"]: r["description"] for r in result}
|
||||
assert "Competitor: Delta" == descs["Delta"]
|
||||
assert "variation" in descs["DL"].lower()
|
||||
|
||||
|
||||
class TestBuildRecommendationBlockedWords:
|
||||
"""Tests for _build_recommendation_blocked_words."""
|
||||
|
||||
def test_generates_prefix_combinations(self):
|
||||
all_names = {"Delta": ["Delta"]}
|
||||
result = _build_recommendation_blocked_words(["Delta"], all_names)
|
||||
keywords = [r["keyword"] for r in result]
|
||||
assert "try Delta" in keywords
|
||||
assert "use Delta" in keywords
|
||||
assert "switch to Delta" in keywords
|
||||
assert "consider Delta" in keywords
|
||||
|
||||
def test_includes_variations(self):
|
||||
all_names = {"Delta": ["Delta", "DL"]}
|
||||
result = _build_recommendation_blocked_words(["Delta"], all_names)
|
||||
keywords = [r["keyword"] for r in result]
|
||||
assert "try DL" in keywords
|
||||
|
||||
|
||||
class TestBuildComparisonBlockedWords:
|
||||
"""Tests for _build_comparison_blocked_words."""
|
||||
|
||||
def test_generates_competitor_comparisons(self):
|
||||
all_names = {"Delta": ["Delta"]}
|
||||
result = _build_comparison_blocked_words(["Delta"], all_names, "Emirates")
|
||||
keywords = [r["keyword"] for r in result]
|
||||
assert "Delta is better" in keywords
|
||||
|
||||
def test_generates_brand_comparisons_once(self):
|
||||
all_names = {"Delta": ["Delta"], "United": ["United"]}
|
||||
result = _build_comparison_blocked_words(["Delta", "United"], all_names, "Emirates")
|
||||
keywords = [r["keyword"] for r in result]
|
||||
# Brand-level entries should appear exactly once
|
||||
assert keywords.count("better than Emirates") == 1
|
||||
assert keywords.count("Emirates is worse") == 1
|
||||
|
||||
def test_includes_variation_comparisons(self):
|
||||
all_names = {"Delta": ["Delta", "DL"]}
|
||||
result = _build_comparison_blocked_words(["Delta"], all_names, "Emirates")
|
||||
keywords = [r["keyword"] for r in result]
|
||||
assert "DL is better" in keywords
|
||||
|
||||
|
||||
class TestBuildCompetitorGuardrailDefinitions:
|
||||
"""Tests for _build_competitor_guardrail_definitions."""
|
||||
|
||||
def test_populates_blocked_words_for_known_guardrail_names(self):
|
||||
definitions = [
|
||||
{
|
||||
"guardrail_name": "competitor-name-blocker",
|
||||
"litellm_params": {"blocked_words": []},
|
||||
},
|
||||
{
|
||||
"guardrail_name": "competitor-recommendation-filter",
|
||||
"litellm_params": {"blocked_words": []},
|
||||
},
|
||||
]
|
||||
result = _build_competitor_guardrail_definitions(
|
||||
definitions, ["Delta"], "Emirates", {"Delta": ["DL"]}
|
||||
)
|
||||
# Name blocker should have entries
|
||||
name_blocker = next(d for d in result if d["guardrail_name"] == "competitor-name-blocker")
|
||||
assert len(name_blocker["litellm_params"]["blocked_words"]) > 0
|
||||
|
||||
# Recommendation filter should have entries
|
||||
rec_filter = next(d for d in result if d["guardrail_name"] == "competitor-recommendation-filter")
|
||||
assert len(rec_filter["litellm_params"]["blocked_words"]) > 0
|
||||
|
||||
def test_does_not_modify_unknown_guardrail_names(self):
|
||||
definitions = [
|
||||
{
|
||||
"guardrail_name": "some-other-guardrail",
|
||||
"litellm_params": {"blocked_words": ["original"]},
|
||||
},
|
||||
]
|
||||
result = _build_competitor_guardrail_definitions(
|
||||
definitions, ["Delta"], "Emirates"
|
||||
)
|
||||
assert result[0]["litellm_params"]["blocked_words"] == ["original"]
|
||||
|
||||
def test_does_not_mutate_original_definitions(self):
|
||||
definitions = [
|
||||
{
|
||||
"guardrail_name": "competitor-name-blocker",
|
||||
"litellm_params": {"blocked_words": []},
|
||||
},
|
||||
]
|
||||
_build_competitor_guardrail_definitions(definitions, ["Delta"], "Emirates")
|
||||
# Original should be unchanged
|
||||
assert definitions[0]["litellm_params"]["blocked_words"] == []
|
||||
|
||||
def test_handles_input_and_output_blocker_variants(self):
|
||||
definitions = [
|
||||
{
|
||||
"guardrail_name": "competitor-name-input-blocker",
|
||||
"litellm_params": {"blocked_words": []},
|
||||
},
|
||||
{
|
||||
"guardrail_name": "competitor-name-output-blocker",
|
||||
"litellm_params": {"blocked_words": []},
|
||||
},
|
||||
]
|
||||
result = _build_competitor_guardrail_definitions(
|
||||
definitions, ["Delta"], "Emirates"
|
||||
)
|
||||
for defn in result:
|
||||
assert len(defn["litellm_params"]["blocked_words"]) > 0
|
||||
|
||||
@@ -5555,19 +5555,24 @@ export const getPolicyTemplates = async (accessToken: string) => {
|
||||
export const enrichPolicyTemplate = async (
|
||||
accessToken: string,
|
||||
templateId: string,
|
||||
parameters: Record<string, string>
|
||||
parameters: Record<string, string>,
|
||||
model?: string,
|
||||
competitors?: string[]
|
||||
) => {
|
||||
try {
|
||||
const url = proxyBaseUrl
|
||||
? `${proxyBaseUrl}/policy/templates/enrich`
|
||||
: `/policy/templates/enrich`;
|
||||
const body: any = { template_id: templateId, parameters };
|
||||
if (model) body.model = model;
|
||||
if (competitors) body.competitors = competitors;
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ template_id: templateId, parameters }),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -5585,6 +5590,78 @@ export const enrichPolicyTemplate = async (
|
||||
}
|
||||
};
|
||||
|
||||
export const enrichPolicyTemplateStream = async (
|
||||
accessToken: string,
|
||||
templateId: string,
|
||||
parameters: Record<string, string>,
|
||||
model: string,
|
||||
onCompetitor: (name: string) => void,
|
||||
onDone: (result: {
|
||||
competitors: string[];
|
||||
competitor_variations: Record<string, string[]>;
|
||||
guardrailDefinitions: any[];
|
||||
}) => void,
|
||||
onError?: (error: string) => void,
|
||||
options?: { instruction?: string; existingCompetitors?: string[] },
|
||||
onStatus?: (message: string) => void
|
||||
) => {
|
||||
const url = proxyBaseUrl
|
||||
? `${proxyBaseUrl}/policy/templates/enrich/stream`
|
||||
: `/policy/templates/enrich/stream`;
|
||||
const body: any = { template_id: templateId, parameters, model };
|
||||
if (options?.instruction) body.instruction = options.instruction;
|
||||
if (options?.existingCompetitors) body.competitors = options.existingCompetitors;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
const errorMessage = deriveErrorMessage(errorData);
|
||||
handleError(errorMessage);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) throw new Error("No response body");
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith("data: ")) continue;
|
||||
try {
|
||||
const event = JSON.parse(line.slice(6));
|
||||
if (event.type === "competitor") {
|
||||
onCompetitor(event.name);
|
||||
} else if (event.type === "status") {
|
||||
onStatus?.(event.message);
|
||||
} else if (event.type === "done") {
|
||||
onDone(event);
|
||||
} else if (event.type === "error") {
|
||||
onError?.(event.message);
|
||||
}
|
||||
} catch {
|
||||
// skip malformed events
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const createPolicyCall = async (accessToken: string, policyData: any) => {
|
||||
try {
|
||||
const url = proxyBaseUrl ? `${proxyBaseUrl}/policies` : `/policies`;
|
||||
|
||||
@@ -222,6 +222,31 @@ const GuardrailSelectionModal: React.FC<GuardrailSelectionModalProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Discovered Competitors */}
|
||||
{template?.discoveredCompetitors?.length > 0 && (
|
||||
<>
|
||||
<Divider />
|
||||
<div className="p-3 bg-purple-50 rounded-lg border border-purple-100">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-lg">✨</span>
|
||||
<span className="font-medium text-purple-900 text-sm">
|
||||
AI-Discovered Competitors ({template.discoveredCompetitors.length})
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{template.discoveredCompetitors.map((name: string) => (
|
||||
<Tag key={name} color="purple" className="text-xs">
|
||||
{name}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-purple-600 mt-2">
|
||||
These competitor names will be automatically blocked by the competitor-name-blocker guardrail.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Selected Summary */}
|
||||
|
||||
@@ -228,7 +228,10 @@ const PoliciesPanel: React.FC<PoliciesPanelProps> = ({
|
||||
return JSON.parse(templateStr);
|
||||
};
|
||||
|
||||
const handleParameterConfirm = async (parameters: Record<string, string>) => {
|
||||
const handleParameterConfirm = async (
|
||||
parameters: Record<string, string>,
|
||||
enrichmentOptions?: { model?: string; competitors?: string[] }
|
||||
) => {
|
||||
if (!accessToken || !pendingTemplate) return;
|
||||
|
||||
setIsEnrichingTemplate(true);
|
||||
@@ -237,14 +240,20 @@ const PoliciesPanel: React.FC<PoliciesPanelProps> = ({
|
||||
let enrichedTemplate = pendingTemplate;
|
||||
|
||||
if (pendingTemplate.llm_enrichment) {
|
||||
// Call backend to enrich template with LLM-discovered data
|
||||
// Call backend to enrich template with LLM-discovered data (or user-provided competitors)
|
||||
const enrichResult = await enrichPolicyTemplate(
|
||||
accessToken,
|
||||
pendingTemplate.id,
|
||||
parameters
|
||||
parameters,
|
||||
enrichmentOptions?.model,
|
||||
enrichmentOptions?.competitors
|
||||
);
|
||||
// The backend returns the enriched guardrailDefinitions
|
||||
enrichedTemplate = { ...pendingTemplate, guardrailDefinitions: enrichResult.guardrailDefinitions };
|
||||
// The backend returns the enriched guardrailDefinitions + discovered competitors
|
||||
enrichedTemplate = {
|
||||
...pendingTemplate,
|
||||
guardrailDefinitions: enrichResult.guardrailDefinitions,
|
||||
discoveredCompetitors: enrichResult.competitors || [],
|
||||
};
|
||||
}
|
||||
|
||||
// Substitute parameters in template
|
||||
@@ -491,6 +500,7 @@ const PoliciesPanel: React.FC<PoliciesPanelProps> = ({
|
||||
onConfirm={handleParameterConfirm}
|
||||
onCancel={handleParameterCancel}
|
||||
isLoading={isEnrichingTemplate}
|
||||
accessToken={accessToken || ""}
|
||||
/>
|
||||
</TabPanel>
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Modal, Spin } from "antd";
|
||||
import { Modal, Spin, Radio, Select } from "antd";
|
||||
import { Button, TextInput } from "@tremor/react";
|
||||
import { modelHubCall, enrichPolicyTemplateStream } from "../networking";
|
||||
|
||||
interface TemplateParameter {
|
||||
name: string;
|
||||
@@ -13,9 +14,13 @@ interface TemplateParameter {
|
||||
interface TemplateParameterModalProps {
|
||||
visible: boolean;
|
||||
template: any;
|
||||
onConfirm: (parameters: Record<string, string>) => void;
|
||||
onConfirm: (
|
||||
parameters: Record<string, string>,
|
||||
enrichmentOptions?: { model?: string; competitors?: string[] }
|
||||
) => void;
|
||||
onCancel: () => void;
|
||||
isLoading?: boolean;
|
||||
accessToken: string;
|
||||
}
|
||||
|
||||
const TemplateParameterModal: React.FC<TemplateParameterModalProps> = ({
|
||||
@@ -24,10 +29,28 @@ const TemplateParameterModal: React.FC<TemplateParameterModalProps> = ({
|
||||
onConfirm,
|
||||
onCancel,
|
||||
isLoading = false,
|
||||
accessToken,
|
||||
}) => {
|
||||
const [parameterValues, setParameterValues] = useState<Record<string, string>>({});
|
||||
const [competitorMode, setCompetitorMode] = useState<"ai" | "manual">("ai");
|
||||
const [selectedModel, setSelectedModel] = useState<string | undefined>(undefined);
|
||||
const [availableModels, setAvailableModels] = useState<string[]>([]);
|
||||
const [isLoadingModels, setIsLoadingModels] = useState(false);
|
||||
const [competitorTags, setCompetitorTags] = useState<string[]>([]);
|
||||
const [variationsMap, setVariationsMap] = useState<Record<string, string[]>>({});
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [refinementInput, setRefinementInput] = useState("");
|
||||
const [isRefining, setIsRefining] = useState(false);
|
||||
const [hasGenerated, setHasGenerated] = useState(false);
|
||||
const [statusMessage, setStatusMessage] = useState("");
|
||||
|
||||
const parameters: TemplateParameter[] = template?.parameters || [];
|
||||
const hasEnrichment = !!template?.llm_enrichment;
|
||||
const enrichmentParam = hasEnrichment ? template.llm_enrichment.parameter : null;
|
||||
|
||||
const nonEnrichmentParams = hasEnrichment
|
||||
? parameters.filter((p) => p.name !== enrichmentParam)
|
||||
: parameters;
|
||||
|
||||
useEffect(() => {
|
||||
if (visible && template) {
|
||||
@@ -36,15 +59,136 @@ const TemplateParameterModal: React.FC<TemplateParameterModalProps> = ({
|
||||
initial[p.name] = "";
|
||||
});
|
||||
setParameterValues(initial);
|
||||
setCompetitorMode("ai");
|
||||
setSelectedModel(undefined);
|
||||
setCompetitorTags([]);
|
||||
setVariationsMap({});
|
||||
setIsGenerating(false);
|
||||
setRefinementInput("");
|
||||
setIsRefining(false);
|
||||
setHasGenerated(false);
|
||||
setStatusMessage("");
|
||||
}
|
||||
}, [visible, template]);
|
||||
|
||||
const allRequiredFilled = parameters
|
||||
useEffect(() => {
|
||||
if (visible && hasEnrichment && competitorMode === "ai" && availableModels.length === 0) {
|
||||
loadModels();
|
||||
}
|
||||
}, [visible, hasEnrichment, competitorMode]);
|
||||
|
||||
const loadModels = async () => {
|
||||
if (!accessToken) return;
|
||||
setIsLoadingModels(true);
|
||||
try {
|
||||
const fetchedModels = await modelHubCall(accessToken);
|
||||
if (fetchedModels?.data?.length > 0) {
|
||||
const models = fetchedModels.data
|
||||
.map((item: any) => item.model_group as string)
|
||||
.sort();
|
||||
setAvailableModels(models);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching models:", error);
|
||||
} finally {
|
||||
setIsLoadingModels(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerateNames = async () => {
|
||||
if (!accessToken || !selectedModel || !template) return;
|
||||
const brandName = (parameterValues[enrichmentParam || "brand_name"] || "").trim();
|
||||
if (!brandName) return;
|
||||
|
||||
setIsGenerating(true);
|
||||
setCompetitorTags([]);
|
||||
setVariationsMap({});
|
||||
setStatusMessage("");
|
||||
try {
|
||||
await enrichPolicyTemplateStream(
|
||||
accessToken,
|
||||
template.id,
|
||||
parameterValues,
|
||||
selectedModel,
|
||||
(name) => {
|
||||
setCompetitorTags((prev) => [...prev, name]);
|
||||
},
|
||||
(result) => {
|
||||
setCompetitorTags(result.competitors);
|
||||
setVariationsMap(result.competitor_variations || {});
|
||||
setIsGenerating(false);
|
||||
setHasGenerated(true);
|
||||
setStatusMessage("");
|
||||
},
|
||||
(error) => {
|
||||
console.error("Streaming error:", error);
|
||||
setIsGenerating(false);
|
||||
setStatusMessage("");
|
||||
},
|
||||
undefined,
|
||||
(status) => setStatusMessage(status),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error generating competitor names:", error);
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefine = async () => {
|
||||
if (!accessToken || !selectedModel || !template || !refinementInput.trim()) return;
|
||||
|
||||
setIsRefining(true);
|
||||
setStatusMessage("");
|
||||
try {
|
||||
await enrichPolicyTemplateStream(
|
||||
accessToken,
|
||||
template.id,
|
||||
parameterValues,
|
||||
selectedModel,
|
||||
(name) => {
|
||||
setCompetitorTags((prev) => {
|
||||
if (prev.some((t) => t.toLowerCase() === name.toLowerCase())) return prev;
|
||||
return [...prev, name];
|
||||
});
|
||||
},
|
||||
(result) => {
|
||||
setCompetitorTags(result.competitors);
|
||||
setVariationsMap(result.competitor_variations || {});
|
||||
setIsRefining(false);
|
||||
setRefinementInput("");
|
||||
setStatusMessage("");
|
||||
},
|
||||
(error) => {
|
||||
console.error("Refinement error:", error);
|
||||
setIsRefining(false);
|
||||
setStatusMessage("");
|
||||
},
|
||||
{
|
||||
instruction: refinementInput.trim(),
|
||||
existingCompetitors: competitorTags,
|
||||
},
|
||||
(status) => setStatusMessage(status),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error refining competitor names:", error);
|
||||
setIsRefining(false);
|
||||
}
|
||||
};
|
||||
|
||||
const allNonEnrichmentFilled = nonEnrichmentParams
|
||||
.filter((p) => p.required)
|
||||
.every((p) => (parameterValues[p.name] || "").trim().length > 0);
|
||||
|
||||
const brandNameFilled = enrichmentParam
|
||||
? (parameterValues[enrichmentParam] || "").trim().length > 0
|
||||
: true;
|
||||
|
||||
const canContinue = hasEnrichment
|
||||
? allNonEnrichmentFilled && brandNameFilled && competitorTags.length > 0
|
||||
: allNonEnrichmentFilled && brandNameFilled;
|
||||
|
||||
const handleConfirm = () => {
|
||||
onConfirm(parameterValues);
|
||||
onConfirm(parameterValues, { competitors: competitorTags });
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -53,15 +197,13 @@ const TemplateParameterModal: React.FC<TemplateParameterModalProps> = ({
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-1">{template?.title}</h3>
|
||||
<p className="text-sm text-gray-500 font-normal">
|
||||
{template?.llm_enrichment
|
||||
? "Enter your brand name to auto-discover competitors and configure guardrails"
|
||||
: "Configure template parameters"}
|
||||
Configure competitor blocking for your brand
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
open={visible}
|
||||
onCancel={onCancel}
|
||||
width={500}
|
||||
width={700}
|
||||
footer={[
|
||||
<Button key="cancel" variant="secondary" onClick={onCancel} disabled={isLoading}>
|
||||
Cancel
|
||||
@@ -70,18 +212,14 @@ const TemplateParameterModal: React.FC<TemplateParameterModalProps> = ({
|
||||
key="confirm"
|
||||
onClick={handleConfirm}
|
||||
loading={isLoading}
|
||||
disabled={!allRequiredFilled || isLoading}
|
||||
disabled={!canContinue || isLoading}
|
||||
>
|
||||
{isLoading
|
||||
? template?.llm_enrichment
|
||||
? "Discovering competitors..."
|
||||
: "Processing..."
|
||||
: "Continue"}
|
||||
{isLoading ? "Creating guardrails..." : "Continue"}
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<div className="py-4 space-y-4">
|
||||
{parameters.map((param) => (
|
||||
{nonEnrichmentParams.map((param) => (
|
||||
<div key={param.name}>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{param.label}
|
||||
@@ -100,25 +238,168 @@ const TemplateParameterModal: React.FC<TemplateParameterModalProps> = ({
|
||||
</div>
|
||||
))}
|
||||
|
||||
{template?.llm_enrichment && (
|
||||
<div className="mt-4 p-3 bg-blue-50 rounded-lg border border-blue-100">
|
||||
<p className="text-sm text-blue-800">
|
||||
This template uses AI to automatically discover your competitors and configure
|
||||
guardrails. An onboarded LLM will be called to identify competitor names.
|
||||
</p>
|
||||
</div>
|
||||
{hasEnrichment && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Competitor Discovery
|
||||
</label>
|
||||
<Radio.Group
|
||||
value={competitorMode}
|
||||
onChange={(e) => setCompetitorMode(e.target.value)}
|
||||
className="w-full"
|
||||
>
|
||||
<div className="flex gap-3">
|
||||
<Radio.Button value="ai" className="flex-1 text-center">
|
||||
✨ Use AI
|
||||
</Radio.Button>
|
||||
<Radio.Button value="manual" className="flex-1 text-center">
|
||||
Enter Manually
|
||||
</Radio.Button>
|
||||
</div>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
|
||||
{/* Brand Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Your Brand Name
|
||||
<span className="text-red-500 ml-1">*</span>
|
||||
</label>
|
||||
<TextInput
|
||||
placeholder="e.g. Acme Airlines"
|
||||
value={parameterValues[enrichmentParam || "brand_name"] || ""}
|
||||
onChange={(e) =>
|
||||
setParameterValues((prev) => ({
|
||||
...prev,
|
||||
[enrichmentParam || "brand_name"]: e.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{competitorMode === "ai" && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Select Model
|
||||
<span className="text-red-500 ml-1">*</span>
|
||||
</label>
|
||||
<Select
|
||||
placeholder="Select a model to generate names"
|
||||
value={selectedModel}
|
||||
onChange={(value) => setSelectedModel(value)}
|
||||
loading={isLoadingModels}
|
||||
showSearch
|
||||
className="w-full"
|
||||
options={availableModels.map((m) => ({ label: m, value: m }))}
|
||||
filterOption={(input, option) =>
|
||||
(option?.label ?? "").toLowerCase().includes(input.toLowerCase())
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleGenerateNames}
|
||||
loading={isGenerating}
|
||||
disabled={!selectedModel || !brandNameFilled || isGenerating}
|
||||
className="w-full"
|
||||
>
|
||||
{isGenerating ? "✨ Generating names..." : "✨ Generate Competitor Names"}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Competitor Tags */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Competitor Names
|
||||
{competitorTags.length > 0 && (
|
||||
<span className="text-gray-400 font-normal ml-2">
|
||||
({competitorTags.length})
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
<Select
|
||||
mode="tags"
|
||||
style={{ width: "100%" }}
|
||||
placeholder="Type a name and press Enter to add"
|
||||
value={competitorTags}
|
||||
onChange={(values) => setCompetitorTags(values)}
|
||||
tokenSeparators={[","]}
|
||||
open={false}
|
||||
suffixIcon={null}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Type a name and press Enter to add. Click ✕ to remove.
|
||||
</p>
|
||||
{statusMessage && (
|
||||
<div className="flex items-center gap-2 mt-2 p-2 bg-blue-50 rounded border border-blue-100">
|
||||
<Spin size="small" />
|
||||
<span className="text-xs text-blue-700">{statusMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
{Object.keys(variationsMap).length > 0 && !statusMessage && (
|
||||
<p className="text-xs text-green-600 mt-1">
|
||||
✓ {Object.values(variationsMap).flat().length} alternate spellings & variations auto-generated for guardrail matching
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Refinement input — shown after initial generation in AI mode */}
|
||||
{competitorMode === "ai" && hasGenerated && competitorTags.length > 0 && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Refine List
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<TextInput
|
||||
placeholder="e.g. add 10 more from Asia, increase to 50 total..."
|
||||
value={refinementInput}
|
||||
onChange={(e) => setRefinementInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && refinementInput.trim() && !isRefining) {
|
||||
handleRefine();
|
||||
}
|
||||
}}
|
||||
disabled={isRefining}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleRefine}
|
||||
loading={isRefining}
|
||||
disabled={!refinementInput.trim() || isRefining}
|
||||
size="xs"
|
||||
>
|
||||
{isRefining ? "..." : "Send"}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
Give instructions to add, remove, or change competitors. Press Enter to send.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex items-center gap-3 mt-4 p-3 bg-gray-50 rounded-lg">
|
||||
<Spin size="small" />
|
||||
<span className="text-sm text-gray-600">
|
||||
{template?.llm_enrichment
|
||||
? "Using AI to discover competitors..."
|
||||
: "Processing template..."}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{!hasEnrichment &&
|
||||
parameters.map((param) => (
|
||||
<div key={param.name}>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{param.label}
|
||||
{param.required && <span className="text-red-500 ml-1">*</span>}
|
||||
</label>
|
||||
<TextInput
|
||||
placeholder={param.placeholder || ""}
|
||||
value={parameterValues[param.name] || ""}
|
||||
onChange={(e) =>
|
||||
setParameterValues((prev) => ({
|
||||
...prev,
|
||||
[param.name]: e.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user