From a589f8b894e78cce01eb25656856cfea8785fbbf Mon Sep 17 00:00:00 2001 From: Paul Hernandez <60959+phernandez@users.noreply.github.com> Date: Thu, 26 Jun 2025 15:51:58 -0500 Subject: [PATCH] feat: enhance search_notes tool documentation with comprehensive syntax examples (#186) Signed-off-by: phernandez Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> Co-authored-by: Paul Hernandez Co-authored-by: Claude --- src/basic_memory/mcp/tools/search.py | 145 ++++++++++++++++++++------- tests/mcp/test_tool_search.py | 85 ++++++++++++---- 2 files changed, 174 insertions(+), 56 deletions(-) diff --git a/src/basic_memory/mcp/tools/search.py b/src/basic_memory/mcp/tools/search.py index 30eb1748..a39f42fd 100644 --- a/src/basic_memory/mcp/tools/search.py +++ b/src/basic_memory/mcp/tools/search.py @@ -45,13 +45,18 @@ def _format_search_error_response(error_message: str, query: str, search_type: s - Boolean OR: `meeting OR discussion` - Boolean NOT: `project NOT archived` - Grouped: `(project OR planning) AND notes` + - Exact phrases: `"weekly standup meeting"` + - Content-specific: `tag:example` or `category:observation` ## Try again with: ``` - search_notes("INSERT_CLEAN_QUERY_HERE") + search_notes("{clean_query}") ``` - Replace INSERT_CLEAN_QUERY_HERE with your simplified search terms. + ## Alternative search strategies: + - Break into simpler terms: `search_notes("{' '.join(clean_query.split()[:2])}")` + - Try different search types: `search_notes("{clean_query}", search_type="title")` + - Use filtering: `search_notes("{clean_query}", types=["entity"])` """).strip() # Project not found errors (check before general "not found") @@ -85,24 +90,39 @@ def _format_search_error_response(error_message: str, query: str, search_type: s No content found matching '{query}' in the current project. - ## Suggestions to try: + ## Search strategy suggestions: 1. **Broaden your search**: Try fewer or more general terms - Instead of: `{query}` - Try: `{simplified_query}` - 2. **Check spelling**: Verify terms are spelled correctly - 3. **Try different search types**: - - Text search: `search_notes("{query}", search_type="text")` - - Title search: `search_notes("{query}", search_type="title")` - - Permalink search: `search_notes("{query}", search_type="permalink")` + 2. **Check spelling and try variations**: + - Verify terms are spelled correctly + - Try synonyms or related terms - 4. **Use boolean operators**: - - Try OR search for broader results + 3. **Use different search approaches**: + - **Text search**: `search_notes("{query}", search_type="text")` (searches full content) + - **Title search**: `search_notes("{query}", search_type="title")` (searches only titles) + - **Permalink search**: `search_notes("{query}", search_type="permalink")` (searches file paths) - ## Check what content exists: - - Recent activity: `recent_activity(timeframe="7d")` - - List files: `list_directory("/")` - - Browse by folder: `list_directory("/notes")` or `list_directory("/docs")` + 4. **Try boolean operators for broader results**: + - OR search: `search_notes("{' OR '.join(query.split()[:3])}")` + - Remove restrictive terms: Focus on the most important keywords + + 5. **Use filtering to narrow scope**: + - By content type: `search_notes("{query}", types=["entity"])` + - By recent content: `search_notes("{query}", after_date="1 week")` + - By entity type: `search_notes("{query}", entity_types=["observation"])` + + 6. **Try advanced search patterns**: + - Tag search: `search_notes("tag:your-tag")` + - Category search: `search_notes("category:observation")` + - Pattern matching: `search_notes("*{query}*", search_type="permalink")` + + ## Explore what content exists: + - **Recent activity**: `recent_activity(timeframe="7d")` - See what's been updated recently + - **List directories**: `list_directory("/")` - Browse all content + - **Browse by folder**: `list_directory("/notes")` or `list_directory("/docs")` + - **Check project**: `get_current_project()` - Verify you're in the right project """).strip() # Server/API errors @@ -151,25 +171,36 @@ You don't have permission to search in the current project: {error_message} Error searching for '{query}': {error_message} -## General troubleshooting: -1. **Check your query**: Ensure it uses valid search syntax -2. **Try simpler terms**: Use basic words without special characters +## Troubleshooting steps: +1. **Simplify your query**: Try basic words without special characters +2. **Check search syntax**: Ensure boolean operators are correctly formatted 3. **Verify project access**: Make sure you can access the current project -4. **Check recent activity**: `recent_activity(timeframe="7d")` to see if content exists +4. **Test with simple search**: Try `search_notes("test")` to verify search is working -## Alternative approaches: -- Browse files: `list_directory("/")` -- Try different search type: `search_notes("{query}", search_type="title")` -- Search with filters: `search_notes("{query}", types=["entity"])` +## Alternative search approaches: +- **Different search types**: + - Title only: `search_notes("{query}", search_type="title")` + - Permalink patterns: `search_notes("{query}*", search_type="permalink")` +- **With filters**: `search_notes("{query}", types=["entity"])` +- **Recent content**: `search_notes("{query}", after_date="1 week")` +- **Boolean variations**: `search_notes("{' OR '.join(query.split()[:2])}")` -## Need help? -- View recent changes: `recent_activity()` -- List projects: `list_projects()` -- Check current project: `get_current_project()`""" +## Explore your content: +- **Browse files**: `list_directory("/")` - See all available content +- **Recent activity**: `recent_activity(timeframe="7d")` - Check what's been updated +- **Project info**: `get_current_project()` - Verify current project +- **All projects**: `list_projects()` - Switch to different project if needed + +## Search syntax reference: +- **Basic**: `keyword` or `multiple words` +- **Boolean**: `term1 AND term2`, `term1 OR term2`, `term1 NOT term2` +- **Phrases**: `"exact phrase"` +- **Grouping**: `(term1 OR term2) AND term3` +- **Patterns**: `tag:example`, `category:observation`""" @mcp.tool( - description="Search across all content in the knowledge base.", + description="Search across all content in the knowledge base with advanced syntax support.", ) async def search_notes( query: str, @@ -181,24 +212,60 @@ async def search_notes( after_date: Optional[str] = None, project: Optional[str] = None, ) -> SearchResponse | str: - """Search across all content in the knowledge base. + """Search across all content in the knowledge base with comprehensive syntax support. This tool searches the knowledge base using full-text search, pattern matching, or exact permalink lookup. It supports filtering by content type, entity type, - and date. + and date, with advanced boolean and phrase search capabilities. + + ## Search Syntax Examples + + ### Basic Searches + - `search_notes("keyword")` - Find any content containing "keyword" + - `search_notes("exact phrase")` - Search for exact phrase match + + ### Advanced Boolean Searches + - `search_notes("term1 term2")` - Find content with both terms (implicit AND) + - `search_notes("term1 AND term2")` - Explicit AND search (both terms required) + - `search_notes("term1 OR term2")` - Either term can be present + - `search_notes("term1 NOT term2")` - Include term1 but exclude term2 + - `search_notes("(project OR planning) AND notes")` - Grouped boolean logic + + ### Content-Specific Searches + - `search_notes("tag:example")` - Search within specific tags (if supported by content) + - `search_notes("category:observation")` - Filter by observation categories + - `search_notes("author:username")` - Find content by author (if metadata available) + + ### Search Type Examples + - `search_notes("Meeting", search_type="title")` - Search only in titles + - `search_notes("docs/meeting-*", search_type="permalink")` - Pattern match permalinks + - `search_notes("keyword", search_type="text")` - Full-text search (default) + + ### Filtering Options + - `search_notes("query", types=["entity"])` - Search only entities + - `search_notes("query", types=["note", "person"])` - Multiple content types + - `search_notes("query", entity_types=["observation"])` - Filter by entity type + - `search_notes("query", after_date="2024-01-01")` - Recent content only + - `search_notes("query", after_date="1 week")` - Relative date filtering + + ### Advanced Pattern Examples + - `search_notes("project AND (meeting OR discussion)")` - Complex boolean logic + - `search_notes("\"exact phrase\" AND keyword")` - Combine phrase and keyword search + - `search_notes("bug NOT fixed")` - Exclude resolved issues + - `search_notes("docs/2024-*", search_type="permalink")` - Year-based permalink search Args: - query: The search query string + query: The search query string (supports boolean operators, phrases, patterns) page: The page number of results to return (default 1) page_size: The number of results to return per page (default 10) search_type: Type of search to perform, one of: "text", "title", "permalink" (default: "text") types: Optional list of note types to search (e.g., ["note", "person"]) entity_types: Optional list of entity types to filter by (e.g., ["entity", "observation"]) - after_date: Optional date filter for recent content (e.g., "1 week", "2d") + after_date: Optional date filter for recent content (e.g., "1 week", "2d", "2024-01-01") project: Optional project name to search in. If not provided, uses current active project. Returns: - SearchResponse with results and pagination info + SearchResponse with results and pagination info, or helpful error guidance if search fails Examples: # Basic text search @@ -216,16 +283,19 @@ async def search_notes( # Boolean search with grouping results = await search_notes("(project OR planning) AND notes") + # Exact phrase search + results = await search_notes("\"weekly standup meeting\"") + # Search with type filter results = await search_notes( query="meeting notes", types=["entity"], ) - # Search with entity type filter, e.g., note vs + # Search with entity type filter results = await search_notes( query="meeting notes", - types=["entity"], + entity_types=["observation"], ) # Search for recent content @@ -242,6 +312,13 @@ async def search_notes( # Search in specific project results = await search_notes("meeting notes", project="work-project") + + # Complex search with multiple filters + results = await search_notes( + query="(bug OR issue) AND NOT resolved", + types=["entity"], + after_date="2024-01-01" + ) """ # Create a SearchQuery object based on the parameters search_query = SearchQuery() diff --git a/tests/mcp/test_tool_search.py b/tests/mcp/test_tool_search.py index 3492f268..d2f45346 100644 --- a/tests/mcp/test_tool_search.py +++ b/tests/mcp/test_tool_search.py @@ -6,6 +6,7 @@ from unittest.mock import patch from basic_memory.mcp.tools import write_note from basic_memory.mcp.tools.search import search_notes, _format_search_error_response +from basic_memory.schemas.search import SearchResponse @pytest.mark.asyncio @@ -23,9 +24,14 @@ async def test_search_text(client): # Search for it response = await search_notes.fn(query="searchable") - # Verify results - assert len(response.results) > 0 - assert any(r.permalink == "test/test-search-note" for r in response.results) + # Verify results - handle both success and error cases + if isinstance(response, SearchResponse): + # Success case - verify SearchResponse + assert len(response.results) > 0 + assert any(r.permalink == "test/test-search-note" for r in response.results) + else: + # If search failed and returned error message, test should fail with informative message + pytest.fail(f"Search failed with error: {response}") @pytest.mark.asyncio @@ -43,9 +49,14 @@ async def test_search_title(client): # Search for it response = await search_notes.fn(query="Search Note", search_type="title") - # Verify results - assert len(response.results) > 0 - assert any(r.permalink == "test/test-search-note" for r in response.results) + # Verify results - handle both success and error cases + if isinstance(response, str): + # If search failed and returned error message, test should fail with informative message + pytest.fail(f"Search failed with error: {response}") + else: + # Success case - verify SearchResponse + assert len(response.results) > 0 + assert any(r.permalink == "test/test-search-note" for r in response.results) @pytest.mark.asyncio @@ -63,9 +74,14 @@ async def test_search_permalink(client): # Search for it response = await search_notes.fn(query="test/test-search-note", search_type="permalink") - # Verify results - assert len(response.results) > 0 - assert any(r.permalink == "test/test-search-note" for r in response.results) + # Verify results - handle both success and error cases + if isinstance(response, SearchResponse): + # Success case - verify SearchResponse + assert len(response.results) > 0 + assert any(r.permalink == "test/test-search-note" for r in response.results) + else: + # If search failed and returned error message, test should fail with informative message + pytest.fail(f"Search failed with error: {response}") @pytest.mark.asyncio @@ -83,9 +99,14 @@ async def test_search_permalink_match(client): # Search for it response = await search_notes.fn(query="test/test-search-*", search_type="permalink") - # Verify results - assert len(response.results) > 0 - assert any(r.permalink == "test/test-search-note" for r in response.results) + # Verify results - handle both success and error cases + if isinstance(response, SearchResponse): + # Success case - verify SearchResponse + assert len(response.results) > 0 + assert any(r.permalink == "test/test-search-note" for r in response.results) + else: + # If search failed and returned error message, test should fail with informative message + pytest.fail(f"Search failed with error: {response}") @pytest.mark.asyncio @@ -103,9 +124,14 @@ async def test_search_pagination(client): # Search for it response = await search_notes.fn(query="searchable", page=1, page_size=1) - # Verify results - assert len(response.results) == 1 - assert any(r.permalink == "test/test-search-note" for r in response.results) + # Verify results - handle both success and error cases + if isinstance(response, SearchResponse): + # Success case - verify SearchResponse + assert len(response.results) == 1 + assert any(r.permalink == "test/test-search-note" for r in response.results) + else: + # If search failed and returned error message, test should fail with informative message + pytest.fail(f"Search failed with error: {response}") @pytest.mark.asyncio @@ -121,8 +147,13 @@ async def test_search_with_type_filter(client): # Search with type filter response = await search_notes.fn(query="type", types=["note"]) - # Verify all results are entities - assert all(r.type == "entity" for r in response.results) + # Verify results - handle both success and error cases + if isinstance(response, SearchResponse): + # Success case - verify all results are entities + assert all(r.type == "entity" for r in response.results) + else: + # If search failed and returned error message, test should fail with informative message + pytest.fail(f"Search failed with error: {response}") @pytest.mark.asyncio @@ -138,8 +169,13 @@ async def test_search_with_entity_type_filter(client): # Search with entity type filter response = await search_notes.fn(query="type", entity_types=["entity"]) - # Verify all results are entities - assert all(r.type == "entity" for r in response.results) + # Verify results - handle both success and error cases + if isinstance(response, SearchResponse): + # Success case - verify all results are entities + assert all(r.type == "entity" for r in response.results) + else: + # If search failed and returned error message, test should fail with informative message + pytest.fail(f"Search failed with error: {response}") @pytest.mark.asyncio @@ -156,8 +192,13 @@ async def test_search_with_date_filter(client): one_hour_ago = datetime.now() - timedelta(hours=1) response = await search_notes.fn(query="recent", after_date=one_hour_ago.isoformat()) - # Verify we get results within timeframe - assert len(response.results) > 0 + # Verify results - handle both success and error cases + if isinstance(response, SearchResponse): + # Success case - verify we get results within timeframe + assert len(response.results) > 0 + else: + # If search failed and returned error message, test should fail with informative message + pytest.fail(f"Search failed with error: {response}") class TestSearchErrorFormatting: @@ -212,7 +253,7 @@ class TestSearchErrorFormatting: assert "# Search Failed" in result assert "Error searching for 'test query': unknown error" in result - assert "General troubleshooting" in result + assert "## Troubleshooting steps:" in result class TestSearchToolErrorHandling: