44This module provides entity search, system overview, deep search, and state retrieval tools.
55"""
66
7+ import logging
78from typing import Annotated , Any , Literal , cast
89
910from pydantic import Field
1011
1112from .helpers import log_tool_usage
1213from .util_helpers import add_timezone_metadata , coerce_bool_param , parse_string_list_param
1314
15+ logger = logging .getLogger (__name__ )
16+
17+
18+ async def _exact_match_search (
19+ client , query : str , domain_filter : str | None , limit : int
20+ ) -> dict [str , Any ]:
21+ """
22+ Fallback exact match search when fuzzy search fails.
23+
24+ Performs simple substring matching on entity_id and friendly_name.
25+ """
26+ all_entities = await client .get_states ()
27+ query_lower = query .lower ().strip ()
28+
29+ results = []
30+ for entity in all_entities :
31+ entity_id = entity .get ("entity_id" , "" )
32+ attributes = entity .get ("attributes" , {})
33+ friendly_name = attributes .get ("friendly_name" , entity_id )
34+ domain = entity_id .split ("." )[0 ] if "." in entity_id else ""
35+
36+ # Apply domain filter if provided
37+ if domain_filter and domain != domain_filter :
38+ continue
39+
40+ # Check for exact substring match in entity_id or friendly_name
41+ if query_lower in entity_id .lower () or query_lower in friendly_name .lower ():
42+ results .append ({
43+ "entity_id" : entity_id ,
44+ "friendly_name" : friendly_name ,
45+ "domain" : domain ,
46+ "state" : entity .get ("state" , "unknown" ),
47+ "score" : 100 if query_lower == entity_id .lower () or query_lower == friendly_name .lower () else 80 ,
48+ "match_type" : "exact_match" ,
49+ })
50+
51+ # Sort by score descending
52+ results .sort (key = lambda x : x ["score" ], reverse = True )
53+ return {
54+ "success" : True ,
55+ "query" : query ,
56+ "total_matches" : len (results ),
57+ "results" : results [:limit ],
58+ "search_type" : "exact_match" ,
59+ }
60+
61+
62+ async def _partial_results_search (
63+ client , query : str , domain_filter : str | None , limit : int
64+ ) -> dict [str , Any ]:
65+ """
66+ Last resort fallback - return any entities that might be relevant.
67+
68+ Returns entities from the specified domain (if any) or a sample of all entities.
69+ """
70+ all_entities = await client .get_states ()
71+
72+ results = []
73+ for entity in all_entities :
74+ entity_id = entity .get ("entity_id" , "" )
75+ attributes = entity .get ("attributes" , {})
76+ friendly_name = attributes .get ("friendly_name" , entity_id )
77+ domain = entity_id .split ("." )[0 ] if "." in entity_id else ""
78+
79+ # Apply domain filter if provided
80+ if domain_filter and domain != domain_filter :
81+ continue
82+
83+ results .append ({
84+ "entity_id" : entity_id ,
85+ "friendly_name" : friendly_name ,
86+ "domain" : domain ,
87+ "state" : entity .get ("state" , "unknown" ),
88+ "score" : 0 , # No match score for partial results
89+ "match_type" : "partial_listing" ,
90+ })
91+
92+ return {
93+ "success" : True ,
94+ "partial" : True ,
95+ "query" : query ,
96+ "total_matches" : len (results ),
97+ "results" : results [:limit ],
98+ "search_type" : "partial_listing" ,
99+ }
100+
14101
15102def register_search_tools (mcp , client , ** kwargs ):
16103 """Register search and discovery tools with the MCP server."""
@@ -211,14 +298,50 @@ async def ha_search_entities(
211298 domain_list_data ["by_domain" ] = {domain_filter : results }
212299 return await add_timezone_metadata (client , domain_list_data )
213300
214- result = await smart_tools .smart_entity_search (query , limit )
301+ # Graceful degradation with fallback search methods
302+ # 1. Try fuzzy search (primary method)
303+ # 2. If that fails, try exact match
304+ # 3. If that fails, return partial results with warning
305+ # 4. Only error if all methods fail
306+
307+ result = None
308+ warning = None
309+ search_type = "fuzzy_search"
310+
311+ # Step 1: Try fuzzy search
312+ try :
313+ result = await smart_tools .smart_entity_search (query , limit )
314+ search_type = "fuzzy_search"
315+ except Exception as fuzzy_error :
316+ logger .warning (f"Fuzzy search failed, trying exact match: { fuzzy_error } " )
317+
318+ # Step 2: Try exact match fallback
319+ try :
320+ result = await _exact_match_search (client , query , domain_filter , limit )
321+ warning = "Fuzzy search unavailable, using exact match"
322+ search_type = "exact_match"
323+ except Exception as exact_error :
324+ logger .warning (f"Exact match failed, trying partial results: { exact_error } " )
325+
326+ # Step 3: Try partial results fallback
327+ try :
328+ result = await _partial_results_search (client , query , domain_filter , limit )
329+ warning = "Search degraded, returning partial results"
330+ search_type = "partial_listing"
331+ except Exception as partial_error :
332+ # Step 4: All methods failed - raise to outer exception handler
333+ logger .error (f"All search methods failed: { partial_error } " )
334+ raise Exception (
335+ f"All search methods failed. Fuzzy: { fuzzy_error } , "
336+ f"Exact: { exact_error } , Partial: { partial_error } "
337+ ) from partial_error
215338
216339 # Convert 'matches' to 'results' for backward compatibility
217340 if "matches" in result :
218341 result ["results" ] = result .pop ("matches" )
219342
220- # Apply domain filter if provided
221- if domain_filter and "results" in result :
343+ # Apply domain filter if provided (for fuzzy search results)
344+ if domain_filter and "results" in result and search_type == "fuzzy_search" :
222345 filtered_results = [
223346 r for r in result ["results" ] if r .get ("domain" ) == domain_filter
224347 ]
@@ -236,7 +359,13 @@ async def ha_search_entities(
236359 by_domain [domain ].append (entity )
237360 result ["by_domain" ] = by_domain
238361
239- result ["search_type" ] = "fuzzy_search"
362+ result ["search_type" ] = search_type
363+
364+ # Add warning and partial flag if fallback was used
365+ if warning :
366+ result ["warning" ] = warning
367+ result ["partial" ] = True
368+
240369 return await add_timezone_metadata (client , result )
241370
242371 except Exception as e :
0 commit comments