diff --git a/e2e-tests/04-cache-test.py b/e2e-tests/04-cache-test.py index ce76e37702..bdb2349119 100644 --- a/e2e-tests/04-cache-test.py +++ b/e2e-tests/04-cache-test.py @@ -16,7 +16,7 @@ # Add parent directory to path to allow importing common test utilities sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -from tests.test_base import SemanticRouterTestBase +from test_base import SemanticRouterTestBase # Constants ENVOY_URL = "http://localhost:8801" diff --git a/src/semantic-router/pkg/config/config.go b/src/semantic-router/pkg/config/config.go index c72cbce171..edf4fa4567 100644 --- a/src/semantic-router/pkg/config/config.go +++ b/src/semantic-router/pkg/config/config.go @@ -10,8 +10,19 @@ import ( "gopkg.in/yaml.v3" ) +// KeywordRule defines a rule for keyword-based classification. +type KeywordRule struct { + Category string `yaml:"category"` + Operator string `yaml:"operator"` + Keywords []string `yaml:"keywords"` + CaseSensitive bool `yaml:"case_sensitive"` +} + // RouterConfig represents the main configuration for the LLM Router type RouterConfig struct { + // Keyword-based classification rules + KeywordRules []KeywordRule `yaml:"keyword_rules,omitempty"` + // BERT model configuration for Candle BERT similarity comparison BertModel struct { ModelID string `yaml:"model_id"` diff --git a/src/semantic-router/pkg/utils/classification/benchmark_test.go b/src/semantic-router/pkg/utils/classification/benchmark_test.go new file mode 100644 index 0000000000..a51a72f6c8 --- /dev/null +++ b/src/semantic-router/pkg/utils/classification/benchmark_test.go @@ -0,0 +1,54 @@ +package classification + +import ( + "testing" + + "github.com/vllm-project/semantic-router/src/semantic-router/pkg/config" +) + +func BenchmarkKeywordClassifier(b *testing.B) { + rules := []config.KeywordRule{ + { + Category: "test-category-1", + Operator: "AND", + Keywords: []string{"keyword1", "keyword2"}, + }, + { + Category: "test-category-2", + Operator: "OR", + Keywords: []string{"keyword3", "keyword4"}, + CaseSensitive: true, + }, + { + Category: "test-category-3", + Operator: "NOR", + Keywords: []string{"keyword5", "keyword6"}, + }, + } + + classifier := NewKeywordClassifier(rules) + + b.Run("AND match", func(b *testing.B) { + for i := 0; i < b.N; i++ { + _, _, _ = classifier.Classify("this text contains keyword1 and keyword2") + } + }) + + b.Run("OR match", func(b *testing.B) { + for i := 0; i < b.N; i++ { + _, _, _ = classifier.Classify("this text contains keyword3") + } + }) + + b.Run("NOR match", func(b *testing.B) { + for i := 0; i < b.N; i++ { + _, _, _ = classifier.Classify("this text is clean") + } + }) + + b.Run("No match", func(b *testing.B) { + for i := 0; i < b.N; i++ { + _, _, _ = classifier.Classify("this text contains keyword5") + } + }) +} diff --git a/src/semantic-router/pkg/utils/classification/classifier.go b/src/semantic-router/pkg/utils/classification/classifier.go index de7e02ef71..12caed7e99 100644 --- a/src/semantic-router/pkg/utils/classification/classifier.go +++ b/src/semantic-router/pkg/utils/classification/classifier.go @@ -204,6 +204,7 @@ type Classifier struct { jailbreakInference JailbreakInference piiInitializer PIIInitializer piiInference PIIInference + keywordClassifier *KeywordClassifier // Dependencies - MCP-based classifiers mcpCategoryInitializer MCPCategoryInitializer @@ -247,6 +248,12 @@ func withPII(piiMapping *PIIMapping, piiInitializer PIIInitializer, piiInference } } +func withKeywordClassifier(keywordClassifier *KeywordClassifier) option { + return func(c *Classifier) { + c.keywordClassifier = keywordClassifier + } +} + // initModels initializes the models for the classifier func initModels(classifier *Classifier) (*Classifier, error) { // Initialize either in-tree OR MCP-based category classifier @@ -303,6 +310,11 @@ func NewClassifier(cfg *config.RouterConfig, categoryMapping *CategoryMapping, p withPII(piiMapping, createPIIInitializer(), createPIIInference()), } + // Add keyword classifier if configured + if len(cfg.KeywordRules) > 0 { + options = append(options, withKeywordClassifier(NewKeywordClassifier(cfg.KeywordRules))) + } + // Add in-tree classifier if configured if cfg.Classifier.CategoryModel.ModelID != "" { options = append(options, withCategory(categoryMapping, createCategoryInitializer(cfg.Classifier.CategoryModel.UseModernBERT), createCategoryInference(cfg.Classifier.CategoryModel.UseModernBERT))) @@ -342,6 +354,17 @@ func (c *Classifier) initializeCategoryClassifier() error { // ClassifyCategory performs category classification on the given text func (c *Classifier) ClassifyCategory(text string) (string, float64, error) { + // Try keyword classifier first + if c.keywordClassifier != nil { + category, confidence, err := c.keywordClassifier.Classify(text) + if err != nil { + return "", 0.0, err + } + if category != "" { + return category, confidence, nil + } + } + // Try in-tree first if properly configured if c.IsCategoryEnabled() && c.categoryInference != nil { return c.classifyCategoryInTree(text) diff --git a/src/semantic-router/pkg/utils/classification/keyword_classifier.go b/src/semantic-router/pkg/utils/classification/keyword_classifier.go new file mode 100644 index 0000000000..f23d512b91 --- /dev/null +++ b/src/semantic-router/pkg/utils/classification/keyword_classifier.go @@ -0,0 +1,101 @@ +package classification + +import ( + "strings" + + "github.com/vllm-project/semantic-router/src/semantic-router/pkg/config" + "github.com/vllm-project/semantic-router/src/semantic-router/pkg/observability" +) + +// KeywordClassifier implements keyword-based classification logic. +type KeywordClassifier struct { + rules []config.KeywordRule +} + +// NewKeywordClassifier creates a new KeywordClassifier. +func NewKeywordClassifier(rules []config.KeywordRule) *KeywordClassifier { + return &KeywordClassifier{rules: rules} +} + +// Classify performs keyword-based classification on the given text. +func (c *KeywordClassifier) Classify(text string) (string, float64, error) { + for _, rule := range c.rules { + if matched, keywords := c.matches(text, rule); matched { + if len(keywords) > 0 { + observability.Infof( + "Keyword-based classification matched category %q with keywords: %v", + rule.Category, keywords, + ) + } else { + observability.Infof( + "Keyword-based classification matched category %q with a NOR rule.", + rule.Category, + ) + } + return rule.Category, 1.0, nil + } + } + return "", 0.0, nil +} + +// matches checks if the text matches the given keyword rule. +func (c *KeywordClassifier) matches(text string, rule config.KeywordRule) (bool, []string) { + // Default to case-insensitive matching if not specified + caseSensitive := rule.CaseSensitive + var matchedKeywords []string + + // Prepare text for matching + preparedText := text + if !caseSensitive { + preparedText = strings.ToLower(text) + } + + // Check for matches based on the operator + switch rule.Operator { + case "AND": + for _, keyword := range rule.Keywords { + preparedKeyword := keyword + if !caseSensitive { + preparedKeyword = strings.ToLower(keyword) + } + if !strings.Contains(preparedText, preparedKeyword) { + return false, nil + } + matchedKeywords = append(matchedKeywords, keyword) + } + return true, matchedKeywords + + case "OR": + for _, keyword := range rule.Keywords { + preparedKeyword := keyword + if !caseSensitive { + preparedKeyword = strings.ToLower(keyword) + } + if strings.Contains(preparedText, preparedKeyword) { + // For OR, we can return on the first match. + return true, []string{keyword} + } + } + return false, nil + + case "NOR": + for _, keyword := range rule.Keywords { + preparedKeyword := keyword + if !caseSensitive { + preparedKeyword = strings.ToLower(keyword) + } + if strings.Contains(preparedText, preparedKeyword) { + return false, nil + } + } + // Return true with an empty slice + return true, matchedKeywords + + default: + observability.Warnf( + "KeywordClassifier: unsupported operator %q in rule for category %q. Returning no match.", + rule.Operator, rule.Category, + ) + return false, nil + } +} diff --git a/src/semantic-router/pkg/utils/classification/keyword_classifier_test.go b/src/semantic-router/pkg/utils/classification/keyword_classifier_test.go new file mode 100644 index 0000000000..8d39df0084 --- /dev/null +++ b/src/semantic-router/pkg/utils/classification/keyword_classifier_test.go @@ -0,0 +1,92 @@ +package classification + +import ( + "testing" + + "github.com/vllm-project/semantic-router/src/semantic-router/pkg/config" +) + +func TestKeywordClassifier(t *testing.T) { + rules := []config.KeywordRule{ + { + Category: "test-category-1", + Operator: "AND", + Keywords: []string{"keyword1", "keyword2"}, + }, + { + Category: "test-category-2", + Operator: "OR", + Keywords: []string{"keyword3", "keyword4"}, + CaseSensitive: true, + }, + { + Category: "test-category-3", + Operator: "NOR", + Keywords: []string{"keyword5", "keyword6"}, + }, + } + + tests := []struct { + name string + text string + expected string + }{ + { + name: "AND match", + text: "this text contains keyword1 and keyword2", + expected: "test-category-1", + }, + { + name: "AND no match", + // This text does not match the AND rule. It also does not contain "keyword5" or "keyword6", + // so the NOR rule will match as a fallback. + text: "this text contains keyword1 but not the other", + expected: "test-category-3", + }, + { + name: "OR match", + text: "this text contains keyword3", + expected: "test-category-2", + }, + { + name: "OR no match", + // This text does not match the OR rule. It also does not contain "keyword5" or "keyword6", + // so the NOR rule will match as a fallback. + text: "this text contains nothing of interest", + expected: "test-category-3", + }, + { + name: "NOR match", + text: "this text is clean", + expected: "test-category-3", + }, + { + name: "NOR no match", + // This text contains "keyword5", so the NOR rule will NOT match. + // Since no other rules match, the result should be empty. + text: "this text contains keyword5", + expected: "", + }, + { + name: "Case sensitive no match", + // This text does not match the case-sensitive OR rule. It also does not contain "keyword5" or "keyword6", + // so the NOR rule will match as a fallback. + text: "this text contains KEYWORD3", + expected: "test-category-3", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a new classifier for each test to ensure a clean slate + classifier := NewKeywordClassifier(rules) + category, _, err := classifier.Classify(tt.text) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if category != tt.expected { + t.Errorf("expected category %q, but got %q", tt.expected, category) + } + }) + } +} diff --git a/website/src/components/ScrollToTop/index.tsx b/website/src/components/ScrollToTop/index.tsx new file mode 100644 index 0000000000..0acd35ec1b --- /dev/null +++ b/website/src/components/ScrollToTop/index.tsx @@ -0,0 +1,58 @@ +import React, { useEffect, useState } from 'react' +import styles from './styles.module.css' + +export default function ScrollToTop(): React.ReactElement { + const [isVisible, setIsVisible] = useState(false) + + useEffect(() => { + const toggleVisibility = () => { + if (window.pageYOffset > 300) { + setIsVisible(true) + } + else { + setIsVisible(false) + } + } + + window.addEventListener('scroll', toggleVisibility) + + return () => { + window.removeEventListener('scroll', toggleVisibility) + } + }, []) + + const scrollToTop = () => { + window.scrollTo({ + top: 0, + behavior: 'smooth', + }) + } + + return ( + <> + {isVisible && ( + + )} + + ) +} diff --git a/website/src/components/ScrollToTop/styles.module.css b/website/src/components/ScrollToTop/styles.module.css new file mode 100644 index 0000000000..4d407cedee --- /dev/null +++ b/website/src/components/ScrollToTop/styles.module.css @@ -0,0 +1,93 @@ +.scrollToTop { + position: fixed; + bottom: 2rem; + right: 2rem; + width: 50px; + height: 50px; + border-radius: 50%; + background: linear-gradient(45deg, var(--tech-primary-blue), var(--tech-accent-purple)); + color: white; + border: none; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 4px 12px rgba(9, 105, 218, 0.3); + transition: all 0.3s ease; + z-index: 999; + opacity: 0; + transform: translateY(20px); + animation: fadeInUp 0.3s ease forwards; +} + +@keyframes fadeInUp { + to { + opacity: 1; + transform: translateY(0); + } +} + +.scrollToTop:hover { + transform: translateY(-4px) scale(1.1); + box-shadow: 0 8px 20px rgba(9, 105, 218, 0.4); +} + +.scrollToTop:active { + transform: translateY(-2px) scale(1.05); +} + +[data-theme='dark'] .scrollToTop { + background: linear-gradient(45deg, var(--tech-primary-blue), var(--tech-accent-orange)); + box-shadow: 0 4px 12px rgba(88, 166, 255, 0.4); +} + +[data-theme='dark'] .scrollToTop:hover { + box-shadow: 0 8px 20px rgba(88, 166, 255, 0.5); +} + +@media (max-width: 768px) { + .scrollToTop { + bottom: 1.5rem; + right: 1.5rem; + width: 45px; + height: 45px; + } +} + +.scrollToTop::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + border-radius: 50%; + background: inherit; + opacity: 0; + animation: pulse 2s ease-out infinite; +} + +@keyframes pulse { + 0% { + transform: scale(1); + opacity: 0.5; + } + 50% { + transform: scale(1.1); + opacity: 0.3; + } + 100% { + transform: scale(1.3); + opacity: 0; + } +} + +.scrollToTop svg { + position: relative; + z-index: 1; + transition: transform 0.3s ease; +} + +.scrollToTop:hover svg { + transform: translateY(-2px); +} diff --git a/website/src/theme/Root.tsx b/website/src/theme/Root.tsx new file mode 100644 index 0000000000..24b2133da4 --- /dev/null +++ b/website/src/theme/Root.tsx @@ -0,0 +1,12 @@ +import React from 'react' +import Root from '@theme-original/Root' +import ScrollToTop from '../components/ScrollToTop' + +export default function RootWrapper(props: any): React.ReactElement { + return ( + <> + + + + ) +}