Skip to content

Commit 3589e21

Browse files
committed
fix(api): handle blank search text
Signed-off-by: phernandez <paul@basicmachines.co>
1 parent 7f9fc80 commit 3589e21

6 files changed

Lines changed: 63 additions & 27 deletions

File tree

src/basic_memory/api/v2/routers/search_router.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
V1 uses string-based project names which are less efficient and less stable.
55
"""
66

7+
import asyncio
8+
79
from fastapi import APIRouter, HTTPException, Path
810

911
import logfire
@@ -74,8 +76,10 @@ async def search(
7476
page=page,
7577
page_size=page_size,
7678
):
77-
results = await search_service.search(query, limit=page_size, offset=offset)
78-
total = await search_service.count(query)
79+
results, total = await asyncio.gather(
80+
search_service.search(query, limit=page_size, offset=offset),
81+
search_service.count(query),
82+
)
7983
except SemanticSearchDisabledError as exc:
8084
raise HTTPException(status_code=400, detail=str(exc)) from exc
8185
except SemanticDependenciesMissingError as exc:

src/basic_memory/repository/postgres_search_repository.py

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1012,12 +1012,7 @@ async def count(
10121012
min_similarity: Optional[float] = None,
10131013
) -> int:
10141014
"""Count indexed content matching the Postgres FTS query."""
1015-
mode = (
1016-
retrieval_mode.value
1017-
if isinstance(retrieval_mode, SearchRetrievalMode)
1018-
else str(retrieval_mode)
1019-
)
1020-
if mode != SearchRetrievalMode.FTS.value:
1015+
if retrieval_mode != SearchRetrievalMode.FTS:
10211016
return await super().count(
10221017
search_text=search_text,
10231018
permalink=permalink,

src/basic_memory/repository/search_repository_base.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,9 @@ async def count(
261261
min_similarity: Optional[float] = None,
262262
) -> int:
263263
"""Count results for retrieval modes that cannot use a backend COUNT query."""
264+
# Trigger: vector and hybrid modes rank after embedding lookup, filtering, and fusion.
265+
# Why: that scoring pipeline is not expressible as a portable database COUNT query.
266+
# Outcome: fetch the bounded candidate set and count the final in-memory results.
264267
results = await self.search(
265268
search_text=search_text,
266269
permalink=permalink,

src/basic_memory/repository/sqlite_search_repository.py

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1031,12 +1031,7 @@ async def count(
10311031
min_similarity: Optional[float] = None,
10321032
) -> int:
10331033
"""Count indexed content matching the SQLite FTS query."""
1034-
mode = (
1035-
retrieval_mode.value
1036-
if isinstance(retrieval_mode, SearchRetrievalMode)
1037-
else str(retrieval_mode)
1038-
)
1039-
if mode != SearchRetrievalMode.FTS.value:
1034+
if retrieval_mode != SearchRetrievalMode.FTS:
10401035
return await super().count(
10411036
search_text=search_text,
10421037
permalink=permalink,

src/basic_memory/services/search_service.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -155,10 +155,10 @@ def _prepare_query(self, query: SearchQuery) -> _PreparedSearchQuery | None:
155155
tags = query.tags
156156

157157
# Support tag:<tag> shorthand by mapping to tags filter.
158-
if search_text:
159-
text = search_text.strip()
160-
if text.lower().startswith("tag:"):
161-
tag_values = re.split(r"[,\s]+", text[4:].strip())
158+
if search_text is not None:
159+
search_text = search_text.strip() or None
160+
if search_text and search_text.lower().startswith("tag:"):
161+
tag_values = re.split(r"[,\s]+", search_text[4:].strip())
162162
parsed_tags = [t for t in tag_values if t]
163163
if parsed_tags:
164164
tags = parsed_tags

tests/api/v2/test_search_router.py

Lines changed: 48 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ async def test_search_entities(
5757
)
5858

5959
# Search for the entity
60-
response = await client.post(f"{v2_project_url}/search/", json={"search_text": "Searchable"})
60+
response = await client.post(f"{v2_project_url}/search/", json={"text": "Searchable"})
6161

6262
assert response.status_code == 200
6363
data = response.json()
@@ -243,7 +243,7 @@ async def test_search_with_type_filter(
243243

244244
# Search with type filter
245245
response = await client.post(
246-
f"{v2_project_url}/search/", json={"search_text": "Type", "note_types": ["note"]}
246+
f"{v2_project_url}/search/", json={"text": "Type", "note_types": ["note"]}
247247
)
248248

249249
assert response.status_code == 200
@@ -276,7 +276,7 @@ async def test_search_with_date_filter(
276276
# Search with date filter
277277
response = await client.post(
278278
f"{v2_project_url}/search/",
279-
json={"search_text": "Date Filtered", "after_date": "2024-01-01T00:00:00Z"},
279+
json={"text": "Date Filtered", "after_date": "2024-01-01T00:00:00Z"},
280280
)
281281

282282
assert response.status_code == 200
@@ -297,12 +297,42 @@ async def test_search_empty_query(
297297
assert response.status_code in [200, 422]
298298

299299

300+
@pytest.mark.asyncio
301+
async def test_search_whitespace_text_is_treated_as_empty(
302+
client: AsyncClient,
303+
test_project: Project,
304+
v2_project_url: str,
305+
entity_repository,
306+
search_service,
307+
file_service,
308+
):
309+
"""Whitespace-only text should not become an unfiltered project-wide search."""
310+
entity_data = {
311+
"title": "Whitespace Regression Entity",
312+
"note_type": "note",
313+
"content_type": "text/markdown",
314+
"file_path": "whitespace_regression.md",
315+
"checksum": "whitespace123",
316+
}
317+
await create_test_entity(
318+
test_project, entity_data, entity_repository, search_service, file_service
319+
)
320+
321+
response = await client.post(f"{v2_project_url}/search/", json={"text": " "})
322+
323+
assert response.status_code == 200
324+
data = response.json()
325+
assert data["total"] == 0
326+
assert data["has_more"] is False
327+
assert data["results"] == []
328+
329+
300330
@pytest.mark.asyncio
301331
async def test_search_invalid_project_id(
302332
client: AsyncClient,
303333
):
304334
"""Test searching with invalid project ID returns 404."""
305-
response = await client.post("/v2/projects/999999/search/", json={"search_text": "test"})
335+
response = await client.post("/v2/projects/999999/search/", json={"text": "test"})
306336

307337
assert response.status_code == 404
308338

@@ -342,7 +372,7 @@ async def test_v2_search_endpoints_use_project_id_not_name(
342372
):
343373
"""Test that v2 search endpoints reject string project names."""
344374
# Try to use project name instead of ID - should fail
345-
response = await client.post(f"/v2/{test_project.name}/search/", json={"search_text": "test"})
375+
response = await client.post(f"/v2/{test_project.name}/search/", json={"text": "test"})
346376

347377
# FastAPI path validation should reject non-integer project_id
348378
assert response.status_code in [404, 422]
@@ -358,11 +388,14 @@ class RaisingSearchService:
358388
async def search(self, *args, **kwargs):
359389
raise SemanticSearchDisabledError("Semantic search is disabled for this project.")
360390

391+
async def count(self, *args, **kwargs):
392+
raise SemanticSearchDisabledError("Semantic search is disabled for this project.")
393+
361394
app.dependency_overrides[get_search_service_v2_external] = lambda: RaisingSearchService()
362395
try:
363396
response = await client.post(
364397
f"{v2_project_url}/search/",
365-
json={"search_text": "semantic query", "retrieval_mode": "vector"},
398+
json={"text": "semantic query", "retrieval_mode": "vector"},
366399
)
367400
finally:
368401
app.dependency_overrides.pop(get_search_service_v2_external, None)
@@ -381,11 +414,14 @@ class RaisingSearchService:
381414
async def search(self, *args, **kwargs):
382415
raise SemanticDependenciesMissingError("Semantic dependencies are missing.")
383416

417+
async def count(self, *args, **kwargs):
418+
raise SemanticDependenciesMissingError("Semantic dependencies are missing.")
419+
384420
app.dependency_overrides[get_search_service_v2_external] = lambda: RaisingSearchService()
385421
try:
386422
response = await client.post(
387423
f"{v2_project_url}/search/",
388-
json={"search_text": "semantic query", "retrieval_mode": "hybrid"},
424+
json={"text": "semantic query", "retrieval_mode": "hybrid"},
389425
)
390426
finally:
391427
app.dependency_overrides.pop(get_search_service_v2_external, None)
@@ -404,6 +440,9 @@ class RaisingSearchService:
404440
async def search(self, *args, **kwargs):
405441
raise ValueError("Vector retrieval requires a text query.")
406442

443+
async def count(self, *args, **kwargs):
444+
raise ValueError("Vector retrieval requires a text query.")
445+
407446
app.dependency_overrides[get_search_service_v2_external] = lambda: RaisingSearchService()
408447
try:
409448
response = await client.post(
@@ -517,7 +556,7 @@ async def count(self, *args, **kwargs):
517556
try:
518557
response = await client.post(
519558
f"{v2_project_url}/search/",
520-
json={"search_text": "pricing"},
559+
json={"text": "pricing"},
521560
)
522561
finally:
523562
app.dependency_overrides.pop(get_search_service_v2_external, None)
@@ -561,7 +600,7 @@ async def count(self, *args, **kwargs):
561600
try:
562601
response = await client.post(
563602
f"{v2_project_url}/search/",
564-
json={"search_text": "general"},
603+
json={"text": "general"},
565604
)
566605
finally:
567606
app.dependency_overrides.pop(get_search_service_v2_external, None)

0 commit comments

Comments
 (0)