feat(search): ADR-027 Phase 2 — file-path filter

Add a path_prefix filter to semantic search, honoured on both the MCP tool and
the dense-only visualization/API paths through the shared filter contract.

- build_base_filter_conditions: append FieldCondition(file_path,
  MatchText(path_prefix)) when set. file_path is only on doc_type == "file"
  points, so a non-empty path_prefix implicitly restricts to files.
- Promote path_prefix to an explicit keyword param on the SearchAlgorithm ABC
  and both algorithms; thread it through nc_semantic_search (blank ⇒ no filter),
  the /api/v1 search endpoints, and the viz route.
- Add a file_path TEXT payload index to _PAYLOAD_INDEX_FIELDS (no content
  re-index; idempotent startup migration). MatchText tokenizes on server Qdrant
  and matches by substring on local/embedded qdrant-client — both serve folder
  scoping.
- Update ADR-027 (Phase 2 implemented; readiness table; semantics note). Tests.

Refs ADR-027 Phase 2. Deck #177.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-06-03 00:51:12 +02:00
co-authored by Claude Opus 4.8
parent c2c8dc1a08
commit ab128bef5b
11 changed files with 121 additions and 10 deletions
+25 -3
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
from unittest.mock import AsyncMock
import pytest
from qdrant_client.models import FieldCondition, Range
from qdrant_client.models import FieldCondition, MatchText, Range
from nextcloud_mcp_server.search import access_filter
from nextcloud_mcp_server.search.access_filter import (
@@ -249,14 +249,36 @@ class TestBuildBaseFilterConditions:
isinstance(c, FieldCondition) and c.key == "modified_at" for c in conditions
)
@pytest.mark.unit
@pytest.mark.parametrize("prefix", ["/Projects/Reports", "/Archive"])
def test_path_prefix_appends_file_path_match_text(self, prefix) -> None:
conditions = build_base_filter_conditions("alice", None, path_prefix=prefix)
path_conds = [
c
for c in conditions
if isinstance(c, FieldCondition) and c.key == "file_path"
]
assert len(path_conds) == 1
match = path_conds[0].match
assert isinstance(match, MatchText)
assert match.text == prefix
@pytest.mark.unit
def test_no_path_condition_when_prefix_absent(self) -> None:
conditions = build_base_filter_conditions("alice", None, path_prefix=None)
assert not any(
isinstance(c, FieldCondition) and c.key == "file_path" for c in conditions
)
@pytest.mark.unit
def test_all_filters_compose(self) -> None:
# placeholder + ownership + doc_type + modified_at range = 4 conditions.
# placeholder + ownership + doc_type + modified_at range + file_path = 5.
conditions = build_base_filter_conditions(
"alice",
["alice", "bob"],
doc_type="file",
modified_after=100,
modified_before=200,
path_prefix="/Projects",
)
assert len(conditions) == 4
assert len(conditions) == 5
+7
View File
@@ -88,6 +88,13 @@ def test_modified_at_indexed_as_integer():
assert _PAYLOAD_INDEX_FIELDS.get("modified_at") == PayloadSchemaType.INTEGER
@pytest.mark.unit
def test_file_path_indexed_as_text():
"""ADR-027 Phase 2: the path filter uses MatchText, which needs a TEXT index
on file_path (server Qdrant); local qdrant-client matches by substring."""
assert _PAYLOAD_INDEX_FIELDS.get("file_path") == PayloadSchemaType.TEXT
# ---------------------------------------------------------------------------
# _ensure_payload_indexes
# ---------------------------------------------------------------------------