Skip to content

Commit 36bc147

Browse files
julienldclaude
andauthored
feat(search): add graceful degradation with fallback for ha_search_entities (#231)
Implements issue #214: When the primary fuzzy search fails, the search now attempts fallback methods before returning an error: 1. Try fuzzy search (primary method) 2. If that fails, try exact substring matching 3. If that fails, return partial results listing Response now includes `partial: true` and `warning` fields when fallback methods are used, matching the format specified in the issue. Closes #214 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude <noreply@anthropic.com>
1 parent ae9f3bc commit 36bc147

3 files changed

Lines changed: 468 additions & 4 deletions

File tree

src/ha_mcp/tools/tools_search.py

Lines changed: 133 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,100 @@
44
This module provides entity search, system overview, deep search, and state retrieval tools.
55
"""
66

7+
import logging
78
from typing import Annotated, Any, Literal, cast
89

910
from pydantic import Field
1011

1112
from .helpers import log_tool_usage
1213
from .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

15102
def 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:

tests/src/e2e/tools/test_search_entities.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,3 +225,94 @@ async def test_search_entities_multiple_domains(mcp_client):
225225
# At least one domain should have results
226226
assert any(count > 0 for count in results_summary.values()), \
227227
"Expected at least one domain to have entities"
228+
229+
230+
# ============================================================================
231+
# Tests for graceful degradation (issue #214)
232+
# ============================================================================
233+
234+
235+
@pytest.mark.asyncio
236+
async def test_search_entities_successful_fuzzy_search_no_warning(mcp_client):
237+
"""Test that successful fuzzy search returns no warning or partial flag.
238+
239+
Issue #214: Normal fuzzy search should work without fallback indicators.
240+
"""
241+
logger.info("Testing successful fuzzy search has no fallback indicators")
242+
243+
result = await mcp_client.call_tool(
244+
"ha_search_entities",
245+
{"query": "light", "limit": 5},
246+
)
247+
raw_data = assert_mcp_success(result, "Fuzzy search success")
248+
data = raw_data.get("data", raw_data)
249+
250+
assert data.get("success") is True
251+
assert data.get("search_type") == "fuzzy_search"
252+
# Normal search should NOT have warning or partial flag
253+
assert "warning" not in data or data.get("warning") is None
254+
assert "partial" not in data or data.get("partial") is not True
255+
256+
logger.info("Fuzzy search succeeded without fallback indicators")
257+
258+
259+
@pytest.mark.asyncio
260+
async def test_search_entities_response_structure_issue_214(mcp_client):
261+
"""Test that search response has the expected structure from issue #214.
262+
263+
The response should include:
264+
- success: boolean
265+
- results: array
266+
- search_type: string indicating which method was used
267+
"""
268+
logger.info("Testing response structure for issue #214")
269+
270+
result = await mcp_client.call_tool(
271+
"ha_search_entities",
272+
{"query": "light", "limit": 5},
273+
)
274+
raw_data = assert_mcp_success(result, "Response structure check")
275+
data = raw_data.get("data", raw_data)
276+
277+
# Verify required fields
278+
assert "success" in data, "Response must include 'success' field"
279+
assert "results" in data, "Response must include 'results' field"
280+
assert "search_type" in data, "Response must include 'search_type' field"
281+
assert isinstance(data["results"], list), "Results must be a list"
282+
283+
# search_type should be one of the expected values
284+
valid_search_types = ["fuzzy_search", "exact_match", "partial_listing", "domain_listing"]
285+
assert data["search_type"] in valid_search_types, \
286+
f"search_type '{data['search_type']}' not in {valid_search_types}"
287+
288+
logger.info(f"Response structure valid with search_type: {data['search_type']}")
289+
290+
291+
@pytest.mark.asyncio
292+
async def test_search_entities_fallback_fields_when_present(mcp_client):
293+
"""Test that fallback fields have correct types when present.
294+
295+
Issue #214: When fallback is used, response should include:
296+
- partial: true
297+
- warning: string explaining what happened
298+
"""
299+
logger.info("Testing fallback field types")
300+
301+
result = await mcp_client.call_tool(
302+
"ha_search_entities",
303+
{"query": "light", "limit": 5},
304+
)
305+
raw_data = assert_mcp_success(result, "Fallback field types")
306+
data = raw_data.get("data", raw_data)
307+
308+
# If warning is present, it should be a string
309+
if "warning" in data and data["warning"] is not None:
310+
assert isinstance(data["warning"], str), "warning must be a string"
311+
logger.info(f"Warning present: {data['warning']}")
312+
313+
# If partial is present, it should be a boolean
314+
if "partial" in data and data["partial"] is not None:
315+
assert isinstance(data["partial"], bool), "partial must be a boolean"
316+
logger.info(f"Partial flag: {data['partial']}")
317+
318+
logger.info("Fallback field types are correct")

0 commit comments

Comments
 (0)