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:
Chris Coutinho
2026-06-03 12:51:37 +02:00
co-authored by Claude Opus 4.8
parent b91af923d2
commit de6c4b360d
9 changed files with 269 additions and 50 deletions
+78 -1
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
from unittest.mock import AsyncMock
import pytest
from qdrant_client.models import FieldCondition, MatchText, Range
from qdrant_client.models import FieldCondition, Filter, MatchText, Range
from nextcloud_mcp_server.search import access_filter
from nextcloud_mcp_server.search.access_filter import (
@@ -13,6 +13,7 @@ from nextcloud_mcp_server.search.access_filter import (
build_ownership_filter,
clear_accessible_owners_cache,
list_accessible_owners,
normalize_path_prefixes,
)
@@ -270,6 +271,67 @@ class TestBuildBaseFilterConditions:
isinstance(c, FieldCondition) and c.key == "file_path" for c in conditions
)
@staticmethod
def _path_should_texts(conditions) -> set[str] | None:
"""Return the file_path texts from the nested OR Filter, or None if no
such filter is present. Ignores the ownership Filter (which ORs
user_id/owner_id, not file_path)."""
for cond in conditions:
if not isinstance(cond, Filter) or not cond.should:
continue
if all(
isinstance(c, FieldCondition) and c.key == "file_path"
for c in cond.should
):
return {
c.match.text
for c in cond.should
if isinstance(c, FieldCondition) and isinstance(c.match, MatchText)
}
return None
@pytest.mark.unit
def test_multiple_path_prefixes_or_in_nested_should(self) -> None:
# Two+ folders must OR together: a single nested Filter(should=[...]) is
# appended (not two must conditions, which would AND and match nothing).
conditions = build_base_filter_conditions(
"alice", None, path_prefixes=["/Projects", "/Archive"]
)
assert self._path_should_texts(conditions) == {"/Projects", "/Archive"}
# No bare file_path FieldCondition in must for the multi-folder case.
assert not any(
isinstance(c, FieldCondition) and c.key == "file_path" for c in conditions
)
@pytest.mark.unit
def test_path_prefix_and_path_prefixes_merge_and_dedupe(self) -> None:
# Legacy single + list inputs merge; duplicates collapse so a folder
# passed both ways yields two distinct conditions, not three.
conditions = build_base_filter_conditions(
"alice",
None,
path_prefix="/Projects",
path_prefixes=["/Projects", "/Archive"],
)
assert self._path_should_texts(conditions) == {"/Projects", "/Archive"}
@pytest.mark.unit
def test_single_effective_prefix_uses_flat_must_condition(self) -> None:
# When dedupe/blank-stripping leaves exactly one folder, keep the
# original flat MatchText in must rather than a one-element should.
conditions = build_base_filter_conditions(
"alice", None, path_prefixes=["/Projects", " ", "/Projects"]
)
# The only nested Filter should be ownership, never a path OR.
assert self._path_should_texts(conditions) is None
path_conds = [
c
for c in conditions
if isinstance(c, FieldCondition) and c.key == "file_path"
]
assert len(path_conds) == 1
assert path_conds[0].match.text == "/Projects"
@pytest.mark.unit
def test_all_filters_compose(self) -> None:
# placeholder + ownership + doc_type + modified_at range + file_path = 5.
@@ -282,3 +344,18 @@ class TestBuildBaseFilterConditions:
path_prefix="/Projects",
)
assert len(conditions) == 5
class TestNormalizePathPrefixes:
@pytest.mark.unit
def test_empty_inputs_return_empty_list(self) -> None:
assert normalize_path_prefixes(None, None) == []
assert normalize_path_prefixes("", []) == []
assert normalize_path_prefixes(" ", ["", " "]) == []
@pytest.mark.unit
def test_strips_dedupes_and_preserves_order(self) -> None:
result = normalize_path_prefixes(
" /Projects ", ["/Archive", "/Projects", " ", "/Specs"]
)
assert result == ["/Projects", "/Archive", "/Specs"]