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:
co-authored by
Claude Opus 4.8
parent
c2c8dc1a08
commit
ab128bef5b
@@ -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
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user