Skip to content

Commit 53d217f

Browse files
authored
feat: migrate fuzzy search to textdistance (#36)
1 parent 3ba92d5 commit 53d217f

5 files changed

Lines changed: 224 additions & 49 deletions

File tree

AGENTS.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -385,7 +385,7 @@ Home Assistant MCP Server - Current Structure
385385
│ ├── manager.py # MCP prompt templates
386386
│ └── enhanced.py # Enhanced prompts
387387
└── Utils Layer (/src/ha_mcp/utils/)
388-
├── fuzzy_search.py # Fuzzy matching engine with fuzzywuzzy
388+
├── fuzzy_search.py # Fuzzy matching engine with textdistance
389389
├── domain_handlers.py # Home Assistant domain-specific logic
390390
├── operation_manager.py # Async operation management
391391
└── usage_logger.py # Tool usage logging
@@ -416,7 +416,7 @@ Home Assistant MCP Server - Current Structure
416416
- **Connection Management**: Auto-reconnect with exponential backoff
417417

418418
#### Smart Search Engine
419-
- **Fuzzy Matching**: Uses `fuzzywuzzy` with `python-levenshtein` for performance
419+
- **Fuzzy Matching**: Uses `textdistance[extras]` for high-performance similarity scoring
420420
- **Multi-language**: Supports French/English entity naming conventions
421421
- **Area-Based Search**: Groups entities by Home Assistant areas/rooms
422422
- **AI Optimization**: Provides system overviews optimized for AI understanding

pyproject.toml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,10 @@ classifiers = [
2828
dependencies = [
2929
"fastmcp>=2.11.0",
3030
"httpx>=0.27.0",
31-
"fuzzywuzzy>=0.18.0",
31+
"textdistance[extras]>=4.6.0",
3232
"pydantic>=2.5.0",
3333
"python-dotenv>=1.0.0",
3434
"websockets>=12.0",
35-
"python-levenshtein>=0.25.0", # For faster fuzzy matching
3635
]
3736

3837
[project.optional-dependencies]

src/ha_mcp/tools/smart_search.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
from ..client.rest_client import HomeAssistantClient
99
from ..config import get_global_settings
10-
from ..utils.fuzzy_search import create_fuzzy_searcher
10+
from ..utils.fuzzy_search import calculate_partial_ratio, create_fuzzy_searcher
1111

1212
logger = logging.getLogger(__name__)
1313

@@ -637,14 +637,12 @@ def _search_in_dict(self, data: dict[str, Any] | list[Any] | Any, query: str) ->
637637
638638
Returns a fuzzy match score based on how well the query matches values in the data.
639639
"""
640-
from fuzzywuzzy import fuzz
641-
642640
max_score = 0
643641

644642
if isinstance(data, dict):
645643
for key, value in data.items():
646644
# Score the key itself
647-
key_score = fuzz.partial_ratio(query, str(key).lower())
645+
key_score = calculate_partial_ratio(query, str(key).lower())
648646
max_score = max(max_score, key_score)
649647

650648
# Recursively score the value
@@ -658,11 +656,14 @@ def _search_in_dict(self, data: dict[str, Any] | list[Any] | Any, query: str) ->
658656

659657
elif isinstance(data, str):
660658
# Direct fuzzy match on string values
661-
max_score = max(max_score, fuzz.partial_ratio(query, data.lower()))
659+
max_score = max(max_score, calculate_partial_ratio(query, data.lower()))
662660

663661
elif data is not None:
664662
# Convert to string and match
665-
max_score = max(max_score, fuzz.partial_ratio(query, str(data).lower()))
663+
max_score = max(
664+
max_score,
665+
calculate_partial_ratio(query, str(data).lower()),
666+
)
666667

667668
return max_score
668669

src/ha_mcp/utils/fuzzy_search.py

Lines changed: 69 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,12 @@
33
"""
44

55
import logging
6-
from typing import Any
6+
from typing import Any, Iterable
77

8-
from fuzzywuzzy import fuzz, process
8+
import textdistance
9+
10+
11+
_LEVENSHTEIN = textdistance.Levenshtein()
912

1013
logger = logging.getLogger(__name__)
1114

@@ -89,17 +92,17 @@ def _calculate_entity_score(
8992
score += 80
9093

9194
# Fuzzy matching scores
92-
entity_id_ratio = fuzz.ratio(query, entity_id.lower())
93-
friendly_ratio = fuzz.ratio(query, friendly_name.lower())
94-
domain_ratio = fuzz.ratio(query, domain.lower())
95+
entity_id_ratio = calculate_ratio(query, entity_id.lower())
96+
friendly_ratio = calculate_ratio(query, friendly_name.lower())
97+
domain_ratio = calculate_ratio(query, domain.lower())
9598

9699
# Partial ratio for substring matching
97-
entity_partial = fuzz.partial_ratio(query, entity_id.lower())
98-
friendly_partial = fuzz.partial_ratio(query, friendly_name.lower())
100+
entity_partial = calculate_partial_ratio(query, entity_id.lower())
101+
friendly_partial = calculate_partial_ratio(query, friendly_name.lower())
99102

100103
# Token sort ratio for word order independence
101-
entity_token = fuzz.token_sort_ratio(query, entity_id.lower())
102-
friendly_token = fuzz.token_sort_ratio(query, friendly_name.lower())
104+
entity_token = calculate_token_sort_ratio(query, entity_id.lower())
105+
friendly_token = calculate_token_sort_ratio(query, friendly_name.lower())
103106

104107
# Weight the scores
105108
score += max(entity_id_ratio, entity_partial, entity_token) * 0.7
@@ -185,7 +188,7 @@ def search_by_area(
185188
continue
186189

187190
# Fuzzy match on friendly name for room inference
188-
area_score = fuzz.partial_ratio(area_lower, friendly_name.lower())
191+
area_score = calculate_partial_ratio(area_lower, friendly_name.lower())
189192
if area_score >= self.threshold:
190193
inferred_area = self._infer_area_from_name(friendly_name)
191194
if inferred_area not in area_matches:
@@ -260,12 +263,12 @@ def get_smart_suggestions(
260263
areas.add(inferred_area)
261264

262265
# Fuzzy match against domains
263-
domain_matches = process.extract(query, domains, limit=3, scorer=fuzz.ratio)
264-
suggestions.extend([match[0] for match in domain_matches if match[1] >= 60])
266+
domain_matches = extract_best_matches(query, domains, limit=3)
267+
suggestions.extend([match for match, score in domain_matches if score >= 60])
265268

266269
# Fuzzy match against areas
267-
area_matches = process.extract(query, areas, limit=3, scorer=fuzz.ratio)
268-
suggestions.extend([match[0] for match in area_matches if match[1] >= 60])
270+
area_matches = extract_best_matches(query, areas, limit=3)
271+
suggestions.extend([match for match, score in area_matches if score >= 60])
269272

270273
# Add common search patterns
271274
if not suggestions:
@@ -290,3 +293,55 @@ def get_smart_suggestions(
290293
def create_fuzzy_searcher(threshold: int = 60) -> FuzzyEntitySearcher:
291294
"""Create a new fuzzy entity searcher instance."""
292295
return FuzzyEntitySearcher(threshold)
296+
297+
298+
def calculate_ratio(query: str, value: str) -> int:
299+
"""Return the normalized Levenshtein similarity ratio (0-100)."""
300+
if not query and not value:
301+
return 100
302+
303+
max_len = max(len(query), len(value))
304+
if max_len == 0:
305+
return 0
306+
307+
distance = _LEVENSHTEIN.distance(query, value)
308+
similarity = 1 - (distance / max_len)
309+
return int(max(similarity, 0) * 100)
310+
311+
312+
def calculate_partial_ratio(query: str, value: str) -> int:
313+
"""Return the best similarity score for any substring match."""
314+
if not query or not value:
315+
return 0
316+
317+
shorter, longer = (query, value) if len(query) <= len(value) else (value, query)
318+
window = len(shorter)
319+
if window == 0:
320+
return 0
321+
322+
best_score = 0
323+
for start in range(len(longer) - window + 1):
324+
substring = longer[start : start + window]
325+
best_score = max(best_score, calculate_ratio(shorter, substring))
326+
if best_score == 100:
327+
break
328+
329+
return best_score
330+
331+
332+
def calculate_token_sort_ratio(query: str, value: str) -> int:
333+
"""Return similarity ratio after token sorting."""
334+
query_sorted = " ".join(sorted(query.split()))
335+
value_sorted = " ".join(sorted(value.split()))
336+
return calculate_ratio(query_sorted, value_sorted)
337+
338+
339+
def extract_best_matches(
340+
query: str, choices: Iterable[str], limit: int = 3
341+
) -> list[tuple[str, int]]:
342+
"""Return the highest scoring matches for a query among choices."""
343+
scored_choices = [
344+
(choice, calculate_ratio(query, choice)) for choice in choices if choice
345+
]
346+
scored_choices.sort(key=lambda item: item[1], reverse=True)
347+
return scored_choices[:limit]

0 commit comments

Comments
 (0)