From 3589e21278582550f4f90679a7bbdcfbd68718ac Mon Sep 17 00:00:00 2001 From: phernandez Date: Mon, 4 May 2026 13:35:13 -0500 Subject: [PATCH] fix(api): handle blank search text Signed-off-by: phernandez --- .../api/v2/routers/search_router.py | 8 ++- .../repository/postgres_search_repository.py | 7 +-- .../repository/search_repository_base.py | 3 + .../repository/sqlite_search_repository.py | 7 +-- src/basic_memory/services/search_service.py | 8 +-- tests/api/v2/test_search_router.py | 57 ++++++++++++++++--- 6 files changed, 63 insertions(+), 27 deletions(-) diff --git a/src/basic_memory/api/v2/routers/search_router.py b/src/basic_memory/api/v2/routers/search_router.py index 71d0dc36..eefc82da 100644 --- a/src/basic_memory/api/v2/routers/search_router.py +++ b/src/basic_memory/api/v2/routers/search_router.py @@ -4,6 +4,8 @@ This router uses external_id UUIDs for stable, API-friendly routing. V1 uses string-based project names which are less efficient and less stable. """ +import asyncio + from fastapi import APIRouter, HTTPException, Path import logfire @@ -74,8 +76,10 @@ async def search( page=page, page_size=page_size, ): - results = await search_service.search(query, limit=page_size, offset=offset) - total = await search_service.count(query) + results, total = await asyncio.gather( + search_service.search(query, limit=page_size, offset=offset), + search_service.count(query), + ) except SemanticSearchDisabledError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc except SemanticDependenciesMissingError as exc: diff --git a/src/basic_memory/repository/postgres_search_repository.py b/src/basic_memory/repository/postgres_search_repository.py index 79cc6e48..8e44f371 100644 --- a/src/basic_memory/repository/postgres_search_repository.py +++ b/src/basic_memory/repository/postgres_search_repository.py @@ -1012,12 +1012,7 @@ class PostgresSearchRepository(SearchRepositoryBase): min_similarity: Optional[float] = None, ) -> int: """Count indexed content matching the Postgres FTS query.""" - mode = ( - retrieval_mode.value - if isinstance(retrieval_mode, SearchRetrievalMode) - else str(retrieval_mode) - ) - if mode != SearchRetrievalMode.FTS.value: + if retrieval_mode != SearchRetrievalMode.FTS: return await super().count( search_text=search_text, permalink=permalink, diff --git a/src/basic_memory/repository/search_repository_base.py b/src/basic_memory/repository/search_repository_base.py index b2c01a92..6a7a0590 100644 --- a/src/basic_memory/repository/search_repository_base.py +++ b/src/basic_memory/repository/search_repository_base.py @@ -261,6 +261,9 @@ class SearchRepositoryBase(ABC): min_similarity: Optional[float] = None, ) -> int: """Count results for retrieval modes that cannot use a backend COUNT query.""" + # Trigger: vector and hybrid modes rank after embedding lookup, filtering, and fusion. + # Why: that scoring pipeline is not expressible as a portable database COUNT query. + # Outcome: fetch the bounded candidate set and count the final in-memory results. results = await self.search( search_text=search_text, permalink=permalink, diff --git a/src/basic_memory/repository/sqlite_search_repository.py b/src/basic_memory/repository/sqlite_search_repository.py index 3c6f511a..2eaec06c 100644 --- a/src/basic_memory/repository/sqlite_search_repository.py +++ b/src/basic_memory/repository/sqlite_search_repository.py @@ -1031,12 +1031,7 @@ class SQLiteSearchRepository(SearchRepositoryBase): min_similarity: Optional[float] = None, ) -> int: """Count indexed content matching the SQLite FTS query.""" - mode = ( - retrieval_mode.value - if isinstance(retrieval_mode, SearchRetrievalMode) - else str(retrieval_mode) - ) - if mode != SearchRetrievalMode.FTS.value: + if retrieval_mode != SearchRetrievalMode.FTS: return await super().count( search_text=search_text, permalink=permalink, diff --git a/src/basic_memory/services/search_service.py b/src/basic_memory/services/search_service.py index c2d4f49b..46b12ba0 100644 --- a/src/basic_memory/services/search_service.py +++ b/src/basic_memory/services/search_service.py @@ -155,10 +155,10 @@ class SearchService: tags = query.tags # Support tag: shorthand by mapping to tags filter. - if search_text: - text = search_text.strip() - if text.lower().startswith("tag:"): - tag_values = re.split(r"[,\s]+", text[4:].strip()) + if search_text is not None: + search_text = search_text.strip() or None + if search_text and search_text.lower().startswith("tag:"): + tag_values = re.split(r"[,\s]+", search_text[4:].strip()) parsed_tags = [t for t in tag_values if t] if parsed_tags: tags = parsed_tags diff --git a/tests/api/v2/test_search_router.py b/tests/api/v2/test_search_router.py index d361875c..58cc091a 100644 --- a/tests/api/v2/test_search_router.py +++ b/tests/api/v2/test_search_router.py @@ -57,7 +57,7 @@ async def test_search_entities( ) # Search for the entity - response = await client.post(f"{v2_project_url}/search/", json={"search_text": "Searchable"}) + response = await client.post(f"{v2_project_url}/search/", json={"text": "Searchable"}) assert response.status_code == 200 data = response.json() @@ -243,7 +243,7 @@ async def test_search_with_type_filter( # Search with type filter response = await client.post( - f"{v2_project_url}/search/", json={"search_text": "Type", "note_types": ["note"]} + f"{v2_project_url}/search/", json={"text": "Type", "note_types": ["note"]} ) assert response.status_code == 200 @@ -276,7 +276,7 @@ async def test_search_with_date_filter( # Search with date filter response = await client.post( f"{v2_project_url}/search/", - json={"search_text": "Date Filtered", "after_date": "2024-01-01T00:00:00Z"}, + json={"text": "Date Filtered", "after_date": "2024-01-01T00:00:00Z"}, ) assert response.status_code == 200 @@ -297,12 +297,42 @@ async def test_search_empty_query( assert response.status_code in [200, 422] +@pytest.mark.asyncio +async def test_search_whitespace_text_is_treated_as_empty( + client: AsyncClient, + test_project: Project, + v2_project_url: str, + entity_repository, + search_service, + file_service, +): + """Whitespace-only text should not become an unfiltered project-wide search.""" + entity_data = { + "title": "Whitespace Regression Entity", + "note_type": "note", + "content_type": "text/markdown", + "file_path": "whitespace_regression.md", + "checksum": "whitespace123", + } + await create_test_entity( + test_project, entity_data, entity_repository, search_service, file_service + ) + + response = await client.post(f"{v2_project_url}/search/", json={"text": " "}) + + assert response.status_code == 200 + data = response.json() + assert data["total"] == 0 + assert data["has_more"] is False + assert data["results"] == [] + + @pytest.mark.asyncio async def test_search_invalid_project_id( client: AsyncClient, ): """Test searching with invalid project ID returns 404.""" - response = await client.post("/v2/projects/999999/search/", json={"search_text": "test"}) + response = await client.post("/v2/projects/999999/search/", json={"text": "test"}) assert response.status_code == 404 @@ -342,7 +372,7 @@ async def test_v2_search_endpoints_use_project_id_not_name( ): """Test that v2 search endpoints reject string project names.""" # Try to use project name instead of ID - should fail - response = await client.post(f"/v2/{test_project.name}/search/", json={"search_text": "test"}) + response = await client.post(f"/v2/{test_project.name}/search/", json={"text": "test"}) # FastAPI path validation should reject non-integer project_id assert response.status_code in [404, 422] @@ -358,11 +388,14 @@ async def test_search_router_returns_400_for_semantic_disabled( async def search(self, *args, **kwargs): raise SemanticSearchDisabledError("Semantic search is disabled for this project.") + async def count(self, *args, **kwargs): + raise SemanticSearchDisabledError("Semantic search is disabled for this project.") + app.dependency_overrides[get_search_service_v2_external] = lambda: RaisingSearchService() try: response = await client.post( f"{v2_project_url}/search/", - json={"search_text": "semantic query", "retrieval_mode": "vector"}, + json={"text": "semantic query", "retrieval_mode": "vector"}, ) finally: app.dependency_overrides.pop(get_search_service_v2_external, None) @@ -381,11 +414,14 @@ async def test_search_router_returns_400_for_semantic_missing_deps( async def search(self, *args, **kwargs): raise SemanticDependenciesMissingError("Semantic dependencies are missing.") + async def count(self, *args, **kwargs): + raise SemanticDependenciesMissingError("Semantic dependencies are missing.") + app.dependency_overrides[get_search_service_v2_external] = lambda: RaisingSearchService() try: response = await client.post( f"{v2_project_url}/search/", - json={"search_text": "semantic query", "retrieval_mode": "hybrid"}, + json={"text": "semantic query", "retrieval_mode": "hybrid"}, ) finally: app.dependency_overrides.pop(get_search_service_v2_external, None) @@ -404,6 +440,9 @@ async def test_search_router_returns_400_for_invalid_vector_query( async def search(self, *args, **kwargs): raise ValueError("Vector retrieval requires a text query.") + async def count(self, *args, **kwargs): + raise ValueError("Vector retrieval requires a text query.") + app.dependency_overrides[get_search_service_v2_external] = lambda: RaisingSearchService() try: response = await client.post( @@ -517,7 +556,7 @@ async def test_search_result_includes_matched_chunk( try: response = await client.post( f"{v2_project_url}/search/", - json={"search_text": "pricing"}, + json={"text": "pricing"}, ) finally: app.dependency_overrides.pop(get_search_service_v2_external, None) @@ -561,7 +600,7 @@ async def test_search_result_omits_matched_chunk_when_none( try: response = await client.post( f"{v2_project_url}/search/", - json={"search_text": "general"}, + json={"text": "general"}, ) finally: app.dependency_overrides.pop(get_search_service_v2_external, None)