Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion e2e-tests/04-cache-test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
11 changes: 11 additions & 0 deletions src/semantic-router/pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
54 changes: 54 additions & 0 deletions src/semantic-router/pkg/utils/classification/benchmark_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
})
}
23 changes: 23 additions & 0 deletions src/semantic-router/pkg/utils/classification/classifier.go
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,7 @@ type Classifier struct {
jailbreakInference JailbreakInference
piiInitializer PIIInitializer
piiInference PIIInference
keywordClassifier *KeywordClassifier

// Dependencies - MCP-based classifiers
mcpCategoryInitializer MCPCategoryInitializer
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)))
Expand Down Expand Up @@ -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)
Expand Down
101 changes: 101 additions & 0 deletions src/semantic-router/pkg/utils/classification/keyword_classifier.go
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
58 changes: 58 additions & 0 deletions website/src/components/ScrollToTop/index.tsx
Original file line number Diff line number Diff line change
@@ -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 && (
<button
onClick={scrollToTop}
className={styles.scrollToTop}
aria-label="Scroll to top"
>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 19V5M12 5L5 12M12 5L19 12"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
)}
</>
)
}
Loading
Loading