feat(search): support multiple folders in the semantic-search path filter
Extend the ADR-027 Phase 2 path filter from a single path_prefix to a list of folders. The new normalize_path_prefixes() helper is the single source of truth for trimming, dropping blanks, and de-duplicating, and folds the legacy single path_prefix into the list for backward compatibility. build_base_filter_conditions() adds one MatchText to the must clause for a single folder (unchanged shape) and OR-s multiple folders via a nested Filter(should=[...]) so a file under any selected folder matches while still AND-ing against the ACL/doc_type/date conditions. path_prefixes is threaded through every search surface: the nc_semantic_search MCP tool, the visualization API (JSON body), and the viz route (CSV query param). The Astrolabe frontend folder picker that produces these lists ships in a companion astrolabe PR. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
b91af923d2
commit
de6c4b360d
@@ -31,7 +31,10 @@ from nextcloud_mcp_server.search import (
|
||||
BM25HybridSearchAlgorithm,
|
||||
SemanticSearchAlgorithm,
|
||||
)
|
||||
from nextcloud_mcp_server.search.access_filter import list_accessible_owners
|
||||
from nextcloud_mcp_server.search.access_filter import (
|
||||
list_accessible_owners,
|
||||
normalize_path_prefixes,
|
||||
)
|
||||
from nextcloud_mcp_server.search.context import (
|
||||
get_chunk_bbox_and_page_from_qdrant,
|
||||
get_chunk_with_context,
|
||||
@@ -225,8 +228,21 @@ async def unified_search(request: Request) -> JSONResponse:
|
||||
include_pca = body.get("include_pca", False)
|
||||
include_chunks = body.get("include_chunks", True)
|
||||
doc_types = body.get("doc_types") # Optional filter
|
||||
# ADR-027 Phase 2 path filter (files only); blank ⇒ no filter.
|
||||
path_prefix = (body.get("path_prefix") or "").strip() or None
|
||||
# ADR-027 Phase 2 path filter (files only); blank ⇒ no filter. Accept a
|
||||
# path_prefixes list (multi-folder) alongside the legacy single
|
||||
# path_prefix; normalize drops blanks and de-dupes.
|
||||
_path_prefixes_raw = body.get("path_prefixes")
|
||||
if isinstance(_path_prefixes_raw, list):
|
||||
_path_prefixes_list = _path_prefixes_raw
|
||||
elif isinstance(_path_prefixes_raw, str):
|
||||
_path_prefixes_list = _path_prefixes_raw.split(",")
|
||||
else:
|
||||
# Ignore any other JSON shape (number, object, null) rather than
|
||||
# blowing up on .split — the legacy path_prefix still applies.
|
||||
_path_prefixes_list = []
|
||||
path_prefixes = normalize_path_prefixes(
|
||||
body.get("path_prefix"), _path_prefixes_list
|
||||
)
|
||||
|
||||
if not query:
|
||||
return JSONResponse({"results": [], "total_found": 0})
|
||||
@@ -268,7 +284,7 @@ async def unified_search(request: Request) -> JSONResponse:
|
||||
accessible_owners=owners,
|
||||
modified_after=modified_after,
|
||||
modified_before=modified_before,
|
||||
path_prefix=path_prefix,
|
||||
path_prefixes=path_prefixes,
|
||||
)
|
||||
)
|
||||
# Sort, then cap to a fixed over-fetch budget before the result
|
||||
@@ -288,7 +304,7 @@ async def unified_search(request: Request) -> JSONResponse:
|
||||
accessible_owners=owners,
|
||||
modified_after=modified_after,
|
||||
modified_before=modified_before,
|
||||
path_prefix=path_prefix,
|
||||
path_prefixes=path_prefixes,
|
||||
)
|
||||
return results
|
||||
|
||||
@@ -440,8 +456,21 @@ async def vector_search(request: Request) -> JSONResponse:
|
||||
limit = min(body.get("limit", 10), 50) # Enforce max limit
|
||||
include_pca = body.get("include_pca", True)
|
||||
doc_types = body.get("doc_types") # Optional list of document types
|
||||
# ADR-027 Phase 2 path filter (files only); blank ⇒ no filter.
|
||||
path_prefix = (body.get("path_prefix") or "").strip() or None
|
||||
# ADR-027 Phase 2 path filter (files only); blank ⇒ no filter. Accept a
|
||||
# path_prefixes list (multi-folder) alongside the legacy single
|
||||
# path_prefix; normalize drops blanks and de-dupes.
|
||||
_path_prefixes_raw = body.get("path_prefixes")
|
||||
if isinstance(_path_prefixes_raw, list):
|
||||
_path_prefixes_list = _path_prefixes_raw
|
||||
elif isinstance(_path_prefixes_raw, str):
|
||||
_path_prefixes_list = _path_prefixes_raw.split(",")
|
||||
else:
|
||||
# Ignore any other JSON shape (number, object, null) rather than
|
||||
# blowing up on .split — the legacy path_prefix still applies.
|
||||
_path_prefixes_list = []
|
||||
path_prefixes = normalize_path_prefixes(
|
||||
body.get("path_prefix"), _path_prefixes_list
|
||||
)
|
||||
# ADR-027 modified-date range filter. Accepts RFC 3339 / ISO 8601
|
||||
# datetimes or Unix seconds; normalized to int Unix seconds. None ⇒ open.
|
||||
try:
|
||||
@@ -507,7 +536,7 @@ async def vector_search(request: Request) -> JSONResponse:
|
||||
accessible_owners=owners,
|
||||
modified_after=modified_after,
|
||||
modified_before=modified_before,
|
||||
path_prefix=path_prefix,
|
||||
path_prefixes=path_prefixes,
|
||||
)
|
||||
)
|
||||
# Sort merged results by score and limit
|
||||
@@ -522,7 +551,7 @@ async def vector_search(request: Request) -> JSONResponse:
|
||||
accessible_owners=owners,
|
||||
modified_after=modified_after,
|
||||
modified_before=modified_before,
|
||||
path_prefix=path_prefix,
|
||||
path_prefixes=path_prefixes,
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
Reference in New Issue
Block a user