Skip to content
Open
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
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ wheels/
*.egg-info
# Virtual environments
.venv
venv/
optimized_matcher.py
optimized_test.py
# Pytest cache
tests/
tests/
2 changes: 2 additions & 0 deletions nova/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
KeywordPattern,
SemanticPattern,
LLMPattern,
FuzzyPattern,
NovaRule
)
from nova.core.matcher import NovaMatcher
Expand All @@ -37,6 +38,7 @@

__all__ = [
'KeywordPattern',
'FuzzyPattern',
'SemanticPattern',
'LLMPattern',
'NovaRule',
Expand Down
2 changes: 2 additions & 0 deletions nova/core/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

from nova.core.rules import (
KeywordPattern,
FuzzyPattern,
SemanticPattern,
LLMPattern,
NovaRule
Expand All @@ -19,6 +20,7 @@

__all__ = [
'KeywordPattern',
'FuzzyPattern',
'SemanticPattern',
'LLMPattern',
'NovaRule',
Expand Down
459 changes: 256 additions & 203 deletions nova/core/matcher.py

Large diffs are not rendered by default.

219 changes: 98 additions & 121 deletions nova/core/parser.py

Large diffs are not rendered by default.

17 changes: 17 additions & 0 deletions nova/core/rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,22 @@ class KeywordPattern:
pattern: str
is_regex: bool = False
case_sensitive: bool = False # Default to case-insensitive
is_fuzzy: bool = False
threshold: int = 80


@dataclass
class FuzzyPattern:
"""
Pattern for fuzzy matching with support for regex and case sensitivity.

Attributes:
pattern: The string or regex pattern to match
case_sensitive: Whether the match should be case-sensitive
"""
pattern: str
threshold: float
case_sensitive: bool = False # Default to case-insensitive


@dataclass
Expand Down Expand Up @@ -70,4 +86,5 @@ class NovaRule:
keywords: Dict[str, KeywordPattern] = field(default_factory=dict)
semantics: Dict[str, SemanticPattern] = field(default_factory=dict)
llms: Dict[str, LLMPattern] = field(default_factory=dict)
fuzzy: Dict[str , FuzzyPattern] = field(default_factory=dict)
condition: str = ""
4 changes: 4 additions & 0 deletions nova/evaluators/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ class KeywordEvaluator(BaseEvaluator):
"""Base class for keyword pattern evaluators."""
pass

class FuzzyEvaluator(BaseEvaluator):
"""Base class for fuzzy pattern evaluators."""
pass


class SemanticEvaluator(BaseEvaluator):
"""Base class for semantic pattern evaluators."""
Expand Down
46 changes: 38 additions & 8 deletions nova/evaluators/condition.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
import re

def evaluate_condition(condition: str, keyword_matches: Dict[str, bool],
semantic_matches: Dict[str, bool], llm_matches: Dict[str, bool] = None) -> bool:
semantic_matches: Dict[str, bool], llm_matches: Dict[str, bool] = None ,
fuzzy_matches: Dict[str, bool] = None) -> bool:
"""
Evaluate a condition expression against pattern match results.
Handles wildcards correctly with improved parsing for complex expressions.
Expand All @@ -32,6 +33,10 @@ def evaluate_condition(condition: str, keyword_matches: Dict[str, bool],
# Initialize llm_matches if not provided
if llm_matches is None:
llm_matches = {}

# Initialise fuzzy_matches if not provided
if fuzzy_matches is None:
fuzzy_matches = {}

# Make a copy of the original condition for debugging
original_condition = condition
Expand Down Expand Up @@ -70,10 +75,14 @@ def evaluate_condition(condition: str, keyword_matches: Dict[str, bool],
if "any of llm.*" in eval_condition:
any_llm = any(llm_matches.values())
eval_condition = eval_condition.replace("any of llm.*", "True" if any_llm else "False")

if "any of fuzzy.*" in eval_condition:
any_fuzzy = any(fuzzy_matches.values())
eval_condition = eval_condition.replace("any of fuzzy.*", "True" if any_fuzzy else "False")

# Handle section-specific prefix wildcards
# Pattern matches "section.$prefix*"
pattern = r'(keywords|semantics|llm)\.\$([a-zA-Z0-9_]+)\*'
pattern = r'(keywords|semantics|llm|fuzzy)\.\$([a-zA-Z0-9_]+)\*'
for match in re.finditer(pattern, eval_condition):
section = match.group(1).lower()
prefix = match.group(2)
Expand All @@ -83,7 +92,8 @@ def evaluate_condition(condition: str, keyword_matches: Dict[str, bool],
matches_dict = {
'keywords': keyword_matches,
'semantics': semantic_matches,
'llm': llm_matches
'llm': llm_matches,
'fuzzy': fuzzy_matches,
}.get(section, {})

# Check if any variable with this prefix matches
Expand All @@ -108,6 +118,10 @@ def evaluate_condition(condition: str, keyword_matches: Dict[str, bool],
if "llm.*" in eval_condition:
any_llm = any(llm_matches.values())
eval_condition = eval_condition.replace("llm.*", "True" if any_llm else "False")

if "fuzzy.*" in eval_condition:
any_fuzzy = any(fuzzy_matches.values())
eval_condition = eval_condition.replace("fuzzy.*", "True" if any_fuzzy else "False")

# Handle "any of" with wildcards - pattern matches: "any of ($prefix*)"
any_of_pattern = r'any\s+of\s+\(\$([a-zA-Z0-9_]+)\*\)'
Expand All @@ -117,7 +131,7 @@ def evaluate_condition(condition: str, keyword_matches: Dict[str, bool],

# Check if any variable with this prefix matches in any section
matches = False
for var_dict in [keyword_matches, semantic_matches, llm_matches]:
for var_dict in [keyword_matches, semantic_matches, llm_matches , fuzzy_matches]:
for var, value in var_dict.items():
if var[1:].startswith(prefix) and value:
matches = True
Expand All @@ -129,7 +143,7 @@ def evaluate_condition(condition: str, keyword_matches: Dict[str, bool],
eval_condition = eval_condition.replace(original, "True" if matches else "False")

# Handle "N of" pattern - replace with actual boolean result
n_of_pattern = r'(\d+)\s+of\s+(keywords|semantics|llm)'
n_of_pattern = r'(\d+)\s+of\s+(keywords|semantics|llm|fuzzy)'
for match in re.finditer(n_of_pattern, eval_condition):
original = match.group(0)
n = int(match.group(1))
Expand All @@ -145,6 +159,9 @@ def evaluate_condition(condition: str, keyword_matches: Dict[str, bool],
elif category == "llm":
match_count = sum(llm_matches.values())
result = match_count >= n
elif category == "fuzzy":
match_count = sum(fuzzy_matches.values())
result = match_count >= n
else:
result = False

Expand All @@ -155,7 +172,7 @@ def evaluate_condition(condition: str, keyword_matches: Dict[str, bool],
# Handle different formats: "section.$var", "$var"

# First, handle fully qualified variables (section.$var)
section_var_pattern = r'(keywords|semantics|llm)\.\$([a-zA-Z0-9_]+)(?!\*)'
section_var_pattern = r'(keywords|semantics|llm|fuzzy)\.\$([a-zA-Z0-9_]+)(?!\*)'
for match in re.finditer(section_var_pattern, eval_condition):
section = match.group(1)
var_name = "$" + match.group(2)
Expand All @@ -169,6 +186,8 @@ def evaluate_condition(condition: str, keyword_matches: Dict[str, bool],
match_value = semantic_matches[var_name]
elif section == "llm" and var_name in llm_matches:
match_value = llm_matches[var_name]
elif section == "fuzzy" and var_name in fuzzy_matches:
match_value = fuzzy_matches[var_name]

# Replace in evaluation condition
eval_condition = eval_condition.replace(original, "True" if match_value else "False")
Expand All @@ -187,6 +206,8 @@ def evaluate_condition(condition: str, keyword_matches: Dict[str, bool],
match_value = semantic_matches[var_name]
elif var_name in llm_matches:
match_value = llm_matches[var_name]
elif var_name in fuzzy_matches:
match_value = fuzzy_matches[var_name]

# Replace in evaluation condition
eval_condition = eval_condition.replace(original, "True" if match_value else "False")
Expand Down Expand Up @@ -232,7 +253,7 @@ def evaluate_condition(condition: str, keyword_matches: Dict[str, bool],
# Special case handling for common patterns that might fail in eval

# If the condition is just a single section.$ reference and there's a match
if re.match(r'^(keywords|semantics|llm)\.\$[a-zA-Z0-9_]+$', original_condition):
if re.match(r'^(keywords|semantics|llm|fuzzy)\.\$[a-zA-Z0-9_]+$', original_condition):
try:
section, var = original_condition.split('.')
if section == "keywords" and var in keyword_matches:
Expand All @@ -241,6 +262,8 @@ def evaluate_condition(condition: str, keyword_matches: Dict[str, bool],
return semantic_matches[var]
elif section == "llm" and var in llm_matches:
return llm_matches[var]
elif section == "fuzzy" and var in fuzzy_matches:
return fuzzy_matches[var]
except Exception:
# If any error occurs in this special case handling, continue to the next one
pass
Expand All @@ -262,13 +285,18 @@ def evaluate_condition(condition: str, keyword_matches: Dict[str, bool],
elif part.startswith("llm.$"):
var = part.replace("llm.", "")
results.append(llm_matches.get(var, False))
elif part.startswith("fuzzy.$"):
var = part.replace("fuzzy.", "")
results.append(fuzzy_matches.get(var, False))
elif part.startswith("$"):
if part in keyword_matches:
results.append(keyword_matches[part])
elif part in semantic_matches:
results.append(semantic_matches[part])
elif part in llm_matches:
results.append(llm_matches[part])
elif part in fuzzy_matches:
results.append(fuzzy_matches[part])
else:
results.append(False)

Expand Down Expand Up @@ -322,12 +350,14 @@ def check_prompt_safe(prompt, matcher_obj):
"matching_keywords": {},
"matching_semantics": {},
"matching_llm": {},
"matching_fuzzy" : {},
"debug": {
"condition": matcher_obj.rule.condition,
"condition_result": False,
"all_keyword_matches": {},
"all_semantic_matches": {},
"all_llm_matches": {}
"all_llm_matches": {},
"all_fuzzy_matches": {}
}
}

Expand Down
59 changes: 59 additions & 0 deletions nova/evaluators/fuzzy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import re
from typing import Dict, Union
from nova.core.rules import KeywordPattern
from nova.evaluators.base import FuzzyEvaluator
from nova.utils.logger import get_logger
try:
from rapidfuzz import fuzz
RAPIDFUZZ_AVAILABLE = True
except ImportError:
RAPIDFUZZ_AVAILABLE = False
fuzz = None

# Get logger for this module
logger = get_logger("nova.evaluators.fuzzy")


class DefaultFuzzyEvaluator(FuzzyEvaluator):
"""Default fuzzy pattern evaluator supporting case sensitivity."""

def __init__(self):
if not RAPIDFUZZ_AVAILABLE:
logger.error("RapidFuzz library not found. Fuzzy matching will fail. Install with: pip install rapidfuzz")


def evaluate(self, pattern: KeywordPattern, text: str, key: str = None) -> bool:
"""
Check if a fuzzy pattern matches the text.

Args:
pattern: The FuzzyPattern to match
text: The text to evaluate

Returns:
Boolean indicating whether the pattern matches
"""

if not RAPIDFUZZ_AVAILABLE or fuzz is None:
return False

if not text or not pattern.pattern:
return False

target = pattern.pattern
content = text

#Handle Case Sensitivity
if not pattern.case_sensitive:
target = target.lower()
content = content.lower()

#Calculate Score
# partial_ratio is best for "needle in haystack"
# It detects "admin" inside "I am administrator" with score 100
score = fuzz.partial_ratio(target, content)

is_match = (score >= pattern.threshold)
return is_match


15 changes: 13 additions & 2 deletions nova/novarun.py
Original file line number Diff line number Diff line change
Expand Up @@ -362,12 +362,12 @@ def print_result(result: Dict[str, Any], rule_path: str, prompt: str, verbose: b
if result['matched']:
print(f"\n{Fore.CYAN}Matching Patterns:")

if result['matching_keywords']:
if 'matching_keywords' in result and result['matching_keywords']:
print(f" {Fore.GREEN}Keywords:")
for key in result['matching_keywords']:
print(f" {Fore.WHITE}• {key}")

if result['matching_semantics']:
if 'matching_semantics' in result and result['matching_semantics']:
print(f" {Fore.GREEN}Semantics:")
for key in result['matching_semantics']:
print(f" {Fore.WHITE}• {key}")
Expand All @@ -376,6 +376,11 @@ def print_result(result: Dict[str, Any], rule_path: str, prompt: str, verbose: b
print(f" {Fore.GREEN}LLM:")
for key in result['matching_llm']:
print(f" {Fore.WHITE}• {key}")

if 'matching_fuzzy' in result and result['matching_fuzzy']:
print(f" {Fore.GREEN}Fuzzy Matches:")
for key in result['matching_fuzzy']:
print(f" {Fore.WHITE}• {key}")

# Print debug information if verbose mode is enabled
if verbose:
Expand All @@ -399,6 +404,12 @@ def print_result(result: Dict[str, Any], rule_path: str, prompt: str, verbose: b
for key, value in debug['all_keyword_matches'].items():
match_color = Fore.GREEN if value else Fore.RED
print(f" {Fore.CYAN}{key}: {match_color}{value}")

if 'all_fuzzy_matches' in debug:
print(f"\n{Fore.MAGENTA}Fuzzy Matches (Detailed):")
for key, value in debug['all_fuzzy_matches'].items():
match_color = Fore.GREEN if value else Fore.RED
print(f" {Fore.CYAN}{key}: {match_color}{value}")

if 'all_semantic_matches' in debug:
print(f"\n{Fore.MAGENTA}Semantic Matches:")
Expand Down