Skip to content

Commit 99f8d07

Browse files
julienldclaude
andauthored
feat: add ha_deep_search tool for searching automation/script/helper configs (#19)
* feat: add ha_deep_search tool for searching within automation/script/helper configs Adds new deep search functionality that searches not only entity names but also within configuration definitions including triggers, actions, sequences, and other config fields. Changes: - Add deep_search() method in SmartSearchTools class - Add _search_in_dict() helper for recursive fuzzy matching - Register ha_deep_search tool in ToolsRegistry - Add comprehensive E2E tests for deep search functionality Features: - Search automations: triggers, actions, conditions - Search scripts: sequences, service calls - Search helpers: options, constraints, configurations - Fuzzy matching with configurable threshold - Results include match_in_name and match_in_config flags - Returns full config for matched items - Sortedby match score with configurable limit Examples: - Find automations using a service: ha_deep_search("light.turn_on") - Find scripts with delays: ha_deep_search("delay") - Find helpers with specific options: ha_deep_search("option_a") - Search all types for an entity: ha_deep_search("sensor.temperature") 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: correct test tool signatures and helper field names Fixes three test failures in deep_search tests: - Automation: Use {"config": ...} parameter format - Script: Add required script_id and config parameters - Helper: Fix tool name (ha_config_remove_helper) and helper_id format - Helper: Check "name" field instead of "friendly_name" These were API signature mismatches, not issues with the deep_search implementation itself. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: add timing delays and fix cleanup tool names in deep search tests - Add 5-second delay after entity creation to allow HA registration - Fix automation cleanup: ha_config_delete_automation -> ha_config_remove_automation - Fix script cleanup: ha_config_delete_script -> ha_config_remove_script - Entities created via API need time to register before becoming searchable * fix: return automations/scripts/helpers at top level in deep_search Tests expect results at top level (data.get("automations")), not nested under results key. This matches the pattern used by other search tools. - Changed return structure from {"results": {"automations": []}} to {"automations": [], "scripts": [], "helpers": []} - Updated error return structure to match * fix: filter out test entities in test_deep_search_no_results Tests run in parallel and share the same HA container. Leftover test entities from other tests can cause false positives in the "no results" test. Filter out any entities with "deep_search" in the name. * feat: enhance overview with area analysis and search best practices - Include area_analysis in all ha_get_overview detail levels (minimal, standard, full) - Add best practice guidance to ha_search_entities: call ha_get_overview first - Overview provides context on smart home scale, language, areas, and capabilities - Helps AI assistants tailor search strategies and understand naming conventions 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * docs: clarify minimal overview is best for search scenarios - Update ha_search_entities guidance to recommend 'minimal' for searches - 'minimal' provides quick orientation with entity samples - 'standard' better for comprehensive tasks - 'full' for deep analysis scenarios 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c43b268 commit 99f8d07

4 files changed

Lines changed: 666 additions & 4 deletions

File tree

src/ha_mcp/tools/registry.py

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,20 @@ async def ha_search_entities(
139139
limit: int = 10,
140140
group_by_domain: bool = False,
141141
) -> dict[str, Any]:
142-
"""Comprehensive entity search with fuzzy matching, domain/area filtering, and optional grouping."""
142+
"""Comprehensive entity search with fuzzy matching, domain/area filtering, and optional grouping.
143+
144+
BEST PRACTICE: Before performing searches or starting any task, call ha_get_overview() first to understand:
145+
- Smart home size and scale (total entities, domains, areas)
146+
- Language used in entity naming (French/English/mixed)
147+
- Available areas/rooms and their entity distribution
148+
- System capabilities (controllable devices, sensors, automations)
149+
150+
Choose overview detail level based on task:
151+
- 'minimal': Quick orientation (10 entities per domain sample) - RECOMMENDED for searches
152+
- 'standard': Complete picture (all entities, friendly names only) - for comprehensive tasks
153+
- 'full': Maximum detail (includes states, device types, services) - for deep analysis
154+
155+
This context helps tailor search strategies, understand naming conventions, and make informed decisions."""
143156
try:
144157
# If area_filter is provided, use area-based search
145158
if area_filter:
@@ -359,6 +372,47 @@ async def ha_get_overview(
359372
)
360373
return cast(dict[str, Any], result)
361374

375+
@self.mcp.tool
376+
@log_tool_usage
377+
async def ha_deep_search(
378+
query: str,
379+
search_types: Annotated[
380+
list[str] | None,
381+
Field(
382+
default=None,
383+
description="Types to search in: 'automation', 'script', 'helper'. Default: all types",
384+
),
385+
] = None,
386+
limit: int = 20,
387+
) -> dict[str, Any]:
388+
"""Deep search across automation, script, and helper definitions.
389+
390+
Searches not only entity names but also within configuration definitions including
391+
triggers, actions, sequences, and other config fields. Perfect for finding automations
392+
that use specific services, helpers referenced in scripts, or tracking down where
393+
particular entities are being used.
394+
395+
Args:
396+
query: Search query (can be partial, with typos)
397+
search_types: Types to search (default: ["automation", "script", "helper"])
398+
limit: Maximum total results to return (default: 20)
399+
400+
Examples:
401+
- Find automations using a service: ha_deep_search("light.turn_on")
402+
- Find scripts with delays: ha_deep_search("delay")
403+
- Find helpers with specific options: ha_deep_search("option_a")
404+
- Search all types for an entity: ha_deep_search("sensor.temperature")
405+
- Search only automations: ha_deep_search("motion", search_types=["automation"])
406+
407+
Returns detailed matches with:
408+
- match_in_name: True if query matched the entity name
409+
- match_in_config: True if query matched within the configuration
410+
- config: Full configuration for matched items
411+
- score: Match quality score (higher is better)
412+
"""
413+
result = await self.smart_tools.deep_search(query, search_types, limit)
414+
return cast(dict[str, Any], result)
415+
362416
@self.mcp.tool
363417
@log_tool_usage
364418
async def ha_get_state(entity_id: str) -> dict[str, Any]:

src/ha_mcp/tools/smart_search.py

Lines changed: 272 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -369,13 +369,13 @@ async def get_system_overview(
369369
"total_areas": len(area_stats),
370370
},
371371
"domain_stats": formatted_domain_stats,
372+
"area_analysis": area_stats, # Now included in all detail levels
372373
"ai_insights": ai_insights,
373374
}
374375

375376
# Add level-specific fields
376377
if detail_level == "full":
377-
# Full: Add area analysis, device types, and service catalog
378-
base_response["area_analysis"] = area_stats
378+
# Full: Add device types and service catalog
379379
base_response["device_types"] = device_types
380380
base_response["service_availability"] = service_stats
381381

@@ -396,6 +396,276 @@ async def get_system_overview(
396396
],
397397
}
398398

399+
async def deep_search(
400+
self,
401+
query: str,
402+
search_types: list[str] | None = None,
403+
limit: int = 20,
404+
) -> dict[str, Any]:
405+
"""
406+
Deep search across automation, script, and helper definitions.
407+
408+
Searches not just entity names but also within configuration definitions
409+
including triggers, actions, sequences, and other config fields.
410+
411+
Args:
412+
query: Search query (can be partial, with typos)
413+
search_types: Types to search (default: ["automation", "script", "helper"])
414+
limit: Maximum total results to return
415+
416+
Returns:
417+
Dictionary with search results grouped by type
418+
"""
419+
if search_types is None:
420+
search_types = ["automation", "script", "helper"]
421+
422+
try:
423+
results: dict[str, list[dict[str, Any]]] = {
424+
"automations": [],
425+
"scripts": [],
426+
"helpers": [],
427+
}
428+
429+
query_lower = query.lower().strip()
430+
431+
# Search automations
432+
if "automation" in search_types:
433+
entities = await self.client.get_states()
434+
automation_entities = [
435+
e for e in entities if e.get("entity_id", "").startswith("automation.")
436+
]
437+
438+
for entity in automation_entities:
439+
entity_id = entity.get("entity_id", "")
440+
friendly_name = entity.get("attributes", {}).get("friendly_name", entity_id)
441+
442+
# Check if query matches in name first
443+
name_match_score = self.fuzzy_searcher._calculate_entity_score(
444+
entity_id, friendly_name, "automation", query_lower
445+
)
446+
447+
# Get automation config and search in definition
448+
try:
449+
config_response = await self.client.get_automation_config(entity_id)
450+
config_match_score = self._search_in_dict(config_response, query_lower)
451+
452+
# Combined score
453+
total_score = max(name_match_score, config_match_score)
454+
455+
if total_score >= self.settings.fuzzy_threshold:
456+
results["automations"].append({
457+
"entity_id": entity_id,
458+
"friendly_name": friendly_name,
459+
"score": total_score,
460+
"match_in_name": name_match_score >= self.settings.fuzzy_threshold,
461+
"match_in_config": config_match_score >= self.settings.fuzzy_threshold,
462+
"config": config_response,
463+
})
464+
except Exception as e:
465+
logger.debug(f"Could not get config for {entity_id}: {e}")
466+
# Still include if name matches
467+
if name_match_score >= self.settings.fuzzy_threshold:
468+
results["automations"].append({
469+
"entity_id": entity_id,
470+
"friendly_name": friendly_name,
471+
"score": name_match_score,
472+
"match_in_name": True,
473+
"match_in_config": False,
474+
})
475+
476+
# Search scripts
477+
if "script" in search_types:
478+
entities = await self.client.get_states()
479+
script_entities = [
480+
e for e in entities if e.get("entity_id", "").startswith("script.")
481+
]
482+
483+
for entity in script_entities:
484+
entity_id = entity.get("entity_id", "")
485+
friendly_name = entity.get("attributes", {}).get("friendly_name", entity_id)
486+
script_id = entity_id.replace("script.", "")
487+
488+
# Check if query matches in name
489+
name_match_score = self.fuzzy_searcher._calculate_entity_score(
490+
entity_id, friendly_name, "script", query_lower
491+
)
492+
493+
# Get script config and search in definition
494+
try:
495+
config_response = await self.client.get_script_config(script_id)
496+
script_config = config_response.get("config", {})
497+
config_match_score = self._search_in_dict(script_config, query_lower)
498+
499+
# Combined score
500+
total_score = max(name_match_score, config_match_score)
501+
502+
if total_score >= self.settings.fuzzy_threshold:
503+
results["scripts"].append({
504+
"entity_id": entity_id,
505+
"script_id": script_id,
506+
"friendly_name": friendly_name,
507+
"score": total_score,
508+
"match_in_name": name_match_score >= self.settings.fuzzy_threshold,
509+
"match_in_config": config_match_score >= self.settings.fuzzy_threshold,
510+
"config": script_config,
511+
})
512+
except Exception as e:
513+
logger.debug(f"Could not get config for {script_id}: {e}")
514+
# Still include if name matches
515+
if name_match_score >= self.settings.fuzzy_threshold:
516+
results["scripts"].append({
517+
"entity_id": entity_id,
518+
"script_id": script_id,
519+
"friendly_name": friendly_name,
520+
"score": name_match_score,
521+
"match_in_name": True,
522+
"match_in_config": False,
523+
})
524+
525+
# Search helpers
526+
if "helper" in search_types:
527+
helper_types = [
528+
"input_boolean",
529+
"input_number",
530+
"input_select",
531+
"input_text",
532+
"input_datetime",
533+
"input_button",
534+
]
535+
536+
for helper_type in helper_types:
537+
try:
538+
# Use WebSocket to list helpers
539+
message = {"type": f"{helper_type}/list"}
540+
helper_list_response = await self.client.send_websocket_message(message)
541+
542+
if not helper_list_response.get("success"):
543+
continue
544+
545+
helpers = helper_list_response.get("result", [])
546+
547+
for helper in helpers:
548+
helper_id = helper.get("id", "")
549+
entity_id = f"{helper_type}.{helper_id}"
550+
name = helper.get("name", helper_id)
551+
552+
# Check if query matches in name or config
553+
name_match_score = self.fuzzy_searcher._calculate_entity_score(
554+
entity_id, name, helper_type, query_lower
555+
)
556+
config_match_score = self._search_in_dict(helper, query_lower)
557+
558+
# Combined score
559+
total_score = max(name_match_score, config_match_score)
560+
561+
if total_score >= self.settings.fuzzy_threshold:
562+
results["helpers"].append({
563+
"entity_id": entity_id,
564+
"helper_type": helper_type,
565+
"name": name,
566+
"score": total_score,
567+
"match_in_name": name_match_score >= self.settings.fuzzy_threshold,
568+
"match_in_config": config_match_score >= self.settings.fuzzy_threshold,
569+
"config": helper,
570+
})
571+
except Exception as e:
572+
logger.debug(f"Could not list {helper_type}: {e}")
573+
574+
# Sort all results by score and apply limit
575+
all_results = []
576+
for result_type, items in results.items():
577+
for item in items:
578+
item["result_type"] = result_type.rstrip("s") # singular form
579+
all_results.append(item)
580+
581+
all_results.sort(key=lambda x: x["score"], reverse=True)
582+
limited_results = all_results[:limit]
583+
584+
# Re-group by type
585+
final_results: dict[str, list[dict[str, Any]]] = {
586+
"automations": [],
587+
"scripts": [],
588+
"helpers": [],
589+
}
590+
for item in limited_results:
591+
result_type = item.pop("result_type")
592+
final_results[f"{result_type}s"].append(item)
593+
594+
total_matches = len(limited_results)
595+
596+
# Return automations/scripts/helpers at top level for easy access
597+
return {
598+
"success": True,
599+
"query": query,
600+
"total_matches": total_matches,
601+
"automations": final_results["automations"],
602+
"scripts": final_results["scripts"],
603+
"helpers": final_results["helpers"],
604+
"search_types": search_types,
605+
"search_metadata": {
606+
"fuzzy_threshold": self.settings.fuzzy_threshold,
607+
"best_match_score": limited_results[0]["score"] if limited_results else 0,
608+
"truncated": len(all_results) > limit,
609+
},
610+
"usage_tips": [
611+
"Deep search finds matches in automation triggers, actions, and conditions",
612+
"Script sequences and service calls are also searched",
613+
"Helper configurations including options and constraints are included",
614+
"Use match_in_name and match_in_config to understand where the match occurred",
615+
],
616+
}
617+
618+
except Exception as e:
619+
logger.error(f"Error in deep_search: {e}")
620+
return {
621+
"success": False,
622+
"query": query,
623+
"error": str(e),
624+
"automations": [],
625+
"scripts": [],
626+
"helpers": [],
627+
"suggestions": [
628+
"Check Home Assistant connection",
629+
"Verify automation/script/helper entities exist",
630+
"Try simpler search terms",
631+
],
632+
}
633+
634+
def _search_in_dict(self, data: dict[str, Any] | list[Any] | Any, query: str) -> int:
635+
"""
636+
Recursively search for query string in nested dictionary/list structures.
637+
638+
Returns a fuzzy match score based on how well the query matches values in the data.
639+
"""
640+
from fuzzywuzzy import fuzz
641+
642+
max_score = 0
643+
644+
if isinstance(data, dict):
645+
for key, value in data.items():
646+
# Score the key itself
647+
key_score = fuzz.partial_ratio(query, str(key).lower())
648+
max_score = max(max_score, key_score)
649+
650+
# Recursively score the value
651+
value_score = self._search_in_dict(value, query)
652+
max_score = max(max_score, value_score)
653+
654+
elif isinstance(data, list):
655+
for item in data:
656+
item_score = self._search_in_dict(item, query)
657+
max_score = max(max_score, item_score)
658+
659+
elif isinstance(data, str):
660+
# Direct fuzzy match on string values
661+
max_score = max(max_score, fuzz.partial_ratio(query, data.lower()))
662+
663+
elif data is not None:
664+
# Convert to string and match
665+
max_score = max(max_score, fuzz.partial_ratio(query, str(data).lower()))
666+
667+
return max_score
668+
399669

400670
def create_smart_search_tools(
401671
client: HomeAssistantClient | None = None,

0 commit comments

Comments
 (0)