33"""
44
55import 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
1013logger = 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(
290293def 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