fix(api): handle blank search text

Signed-off-by: phernandez <paul@basicmachines.co>
This commit is contained in:
phernandez
2026-05-04 13:35:13 -05:00
parent 7f9fc80e27
commit 3589e21278
6 changed files with 63 additions and 27 deletions
@@ -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:
@@ -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,
@@ -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,
@@ -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,
+4 -4
View File
@@ -155,10 +155,10 @@ class SearchService:
tags = query.tags
# Support tag:<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
+48 -9
View File
@@ -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)