feat(search): ADR-027 Phase 1 — modified-date range filter

Add a modified_after/modified_before date-range filter to semantic search,
honoured on both the MCP tool path (BM25HybridSearchAlgorithm) and the
dense-only visualization/API path (SemanticSearchAlgorithm) through one shared
contract.

- Promote modified_after/modified_before to explicit keyword params on the
  SearchAlgorithm ABC and both concrete algorithms; factor the shared
  placeholder+ownership+doc_type+date filter into
  access_filter.build_base_filter_conditions so new filters land in one place.
- nc_semantic_search: accept RFC 3339 / ISO 8601 (or Unix seconds) bounds via
  utils.validation.parse_modified_timestamp; Annotated/Field constraints on the
  numeric args; explicit McpError guard for after > before. Thread the parsed
  bounds through the cross-app and per-doc_type dispatch.
- /api/v1 search endpoints + viz route parse the same formats and 400 on bad or
  inverted ranges.
- Add a modified_at INTEGER payload index to _PAYLOAD_INDEX_FIELDS; the
  idempotent _ensure_payload_indexes() startup path migrates existing
  collections with no content re-index.
- Update ADR-027 to resolve the review feedback (validation placement, shared
  algorithm contract, deferral of nc_semantic_search_answer, payload index,
  RFC-3339-at-the-boundary rationale). Add unit tests.

Refs ADR-027. 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:35:20 +02:00
co-authored by Claude Opus 4.8
parent f6ab04b2d9
commit c2c8dc1a08
13 changed files with 624 additions and 75 deletions
+67 -1
View File
@@ -29,7 +29,16 @@ import time
from collections import OrderedDict
from typing import Any, Protocol
from qdrant_client.models import Condition, FieldCondition, Filter, MatchAny, MatchValue
from qdrant_client.models import (
Condition,
FieldCondition,
Filter,
MatchAny,
MatchValue,
Range,
)
from nextcloud_mcp_server.vector.placeholder import get_placeholder_filter
logger = logging.getLogger(__name__)
@@ -177,3 +186,60 @@ def build_ownership_filter(
0, FieldCondition(key="owner_id", match=MatchAny(any=other_owners))
)
return Filter(should=conditions)
def build_base_filter_conditions(
user_id: str,
accessible_owners: list[str] | None = None,
doc_type: str | None = None,
modified_after: int | None = None,
modified_before: int | None = None,
) -> list[Condition]:
"""Build the common ``must`` conditions shared by every search algorithm.
This is the single place the structured-filter contract (ADR-027) lives, so
both the BM25-hybrid (MCP tool) and dense-only (visualization/API) algorithms
apply identical placeholder/ACL/doc_type/date filtering. Each algorithm wraps
the returned list in ``Filter(must=...)`` and may append its own additive
conditions afterward (e.g. the dense algorithm's opt-in ACL pre-filter).
The conditions, in order:
1. ``get_placeholder_filter()`` — exclude in-flight placeholder points.
2. ``build_ownership_filter(...)`` — ACL-aware ``owner_id``/``user_id`` scope.
3. ``doc_type`` exact match — only when ``doc_type`` is truthy.
4. ``modified_at`` range — only when at least one bound is given.
Args:
user_id: Querying user.
accessible_owners: Owner UIDs the user can read (see
``build_ownership_filter``). ``None`` ⇒ self-only.
doc_type: Optional single document-type filter.
modified_after: Inclusive lower bound on ``modified_at`` (Unix seconds).
modified_before: Inclusive upper bound on ``modified_at`` (Unix seconds).
Returns:
A list of Qdrant ``Condition`` objects for a parent ``must`` clause.
"""
conditions: list[Condition] = [
get_placeholder_filter(),
build_ownership_filter(user_id, accessible_owners),
]
if doc_type:
conditions.append(
FieldCondition(key="doc_type", match=MatchValue(value=doc_type))
)
# ``Range`` treats ``None`` bounds as open-ended, so the same condition serves
# after-only, before-only, and both-bounds queries. Appended only when at
# least one bound is set so unfiltered searches add no condition.
if modified_after is not None or modified_before is not None:
conditions.append(
FieldCondition(
key="modified_at",
range=Range(gte=modified_after, lte=modified_before),
)
)
return conditions
@@ -289,6 +289,8 @@ class SearchAlgorithm(ABC):
doc_type: str | None = None,
*,
accessible_owners: list[str] | None = None,
modified_after: int | None = None,
modified_before: int | None = None,
**kwargs: Any,
) -> list[SearchResult]:
"""Execute search with the given parameters.
@@ -304,6 +306,12 @@ class SearchAlgorithm(ABC):
buried in ``**kwargs`` — so a misspelled keyword is a type error
instead of a silent fall back to self-only scope. ``None`` means
self-only (``[user_id]``).
modified_after: Optional inclusive lower bound on the document's
``modified_at`` payload field (Unix seconds, UTC). Declared
explicitly for the same discoverability/type-safety reason as
``accessible_owners`` (ADR-027). ``None`` ⇒ open-ended.
modified_before: Optional inclusive upper bound on ``modified_at``
(Unix seconds, UTC). ``None`` ⇒ open-ended.
**kwargs: Algorithm-specific parameters
Returns:
+18 -17
View File
@@ -4,19 +4,18 @@ import logging
from typing import Any
from qdrant_client import models
from qdrant_client.models import FieldCondition, Filter, MatchValue
from qdrant_client.models import Filter
from nextcloud_mcp_server.config import get_settings
from nextcloud_mcp_server.embedding import get_bm25_service, get_embedding_service
from nextcloud_mcp_server.observability.metrics import record_qdrant_operation
from nextcloud_mcp_server.observability.tracing import trace_operation
from nextcloud_mcp_server.search.access_filter import build_ownership_filter
from nextcloud_mcp_server.search.access_filter import build_base_filter_conditions
from nextcloud_mcp_server.search.algorithms import (
SearchAlgorithm,
SearchResult,
build_search_result_from_point,
)
from nextcloud_mcp_server.vector.placeholder import get_placeholder_filter
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
logger = logging.getLogger(__name__)
@@ -73,6 +72,8 @@ class BM25HybridSearchAlgorithm(SearchAlgorithm):
doc_type: str | None = None,
*,
accessible_owners: list[str] | None = None,
modified_after: int | None = None,
modified_before: int | None = None,
**kwargs: Any,
) -> list[SearchResult]:
"""
@@ -94,6 +95,10 @@ class BM25HybridSearchAlgorithm(SearchAlgorithm):
accessible_owners: Owner UIDs the user can read (self + share
senders), pre-computed by the caller from the OCS Sharing API.
Defaults to ``[user_id]`` (self-only) when ``None``.
modified_after: Inclusive lower bound on ``modified_at`` (Unix
seconds, UTC); ``None`` ⇒ open-ended (ADR-027).
modified_before: Inclusive upper bound on ``modified_at`` (Unix
seconds, UTC); ``None`` ⇒ open-ended (ADR-027).
**kwargs: Additional parameters (score_threshold override)
Returns:
@@ -134,20 +139,16 @@ class BM25HybridSearchAlgorithm(SearchAlgorithm):
len(sparse_embedding["indices"]),
)
# Build Qdrant filter
filter_conditions = [
get_placeholder_filter(), # Always exclude placeholders from user-facing queries
build_ownership_filter(user_id, accessible_owners),
]
# Add doc_type filter if specified
if doc_type:
filter_conditions.append(
FieldCondition(
key="doc_type",
match=MatchValue(value=doc_type),
)
)
# Build Qdrant filter (placeholder + ACL + doc_type + modified_at range).
# Shared with the dense-only SemanticSearchAlgorithm via the common
# ADR-027 helper so every search surface applies one filter contract.
filter_conditions = build_base_filter_conditions(
user_id=user_id,
accessible_owners=accessible_owners,
doc_type=doc_type,
modified_after=modified_after,
modified_before=modified_before,
)
query_filter = Filter(must=filter_conditions)
+19 -17
View File
@@ -3,20 +3,19 @@
import logging
from typing import Any
from qdrant_client.models import FieldCondition, Filter, MatchAny, MatchValue
from qdrant_client.models import FieldCondition, Filter, MatchAny
from nextcloud_mcp_server.acl_hash import accessible_hash_set
from nextcloud_mcp_server.config import get_settings
from nextcloud_mcp_server.embedding import get_embedding_service
from nextcloud_mcp_server.observability.metrics import record_qdrant_operation
from nextcloud_mcp_server.search.access_filter import build_ownership_filter
from nextcloud_mcp_server.search.access_filter import build_base_filter_conditions
from nextcloud_mcp_server.search.algorithms import (
SearchAlgorithm,
SearchResult,
build_search_result_from_point,
)
from nextcloud_mcp_server.vector.payload_keys import ACL_HASH
from nextcloud_mcp_server.vector.placeholder import get_placeholder_filter
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
logger = logging.getLogger(__name__)
@@ -53,6 +52,8 @@ class SemanticSearchAlgorithm(SearchAlgorithm):
doc_type: str | None = None,
*,
accessible_owners: list[str] | None = None,
modified_after: int | None = None,
modified_before: int | None = None,
**kwargs: Any,
) -> list[SearchResult]:
"""Execute semantic search using vector similarity.
@@ -73,6 +74,10 @@ class SemanticSearchAlgorithm(SearchAlgorithm):
accessible_owners: Owner UIDs the user can read (self + share
senders), pre-computed by the caller from the OCS Sharing API.
Defaults to ``[user_id]`` (self-only) when ``None``.
modified_after: Inclusive lower bound on ``modified_at`` (Unix
seconds, UTC); ``None`` ⇒ open-ended (ADR-027).
modified_before: Inclusive upper bound on ``modified_at`` (Unix
seconds, UTC); ``None`` ⇒ open-ended (ADR-027).
**kwargs:
- score_threshold (float): override the instance default
@@ -103,20 +108,17 @@ class SemanticSearchAlgorithm(SearchAlgorithm):
"Generated embedding for query (dimension=%s)", len(query_embedding)
)
# Build Qdrant filter
filter_conditions = [
get_placeholder_filter(), # Always exclude placeholders from user-facing queries
build_ownership_filter(user_id, accessible_owners),
]
# Add doc_type filter if specified
if doc_type:
filter_conditions.append(
FieldCondition(
key="doc_type",
match=MatchValue(value=doc_type),
)
)
# Build Qdrant filter (placeholder + ACL + doc_type + modified_at range).
# Shared with BM25HybridSearchAlgorithm via the common ADR-027 helper so
# the dense-only (API/visualization) and hybrid (MCP tool) paths apply
# one filter contract.
filter_conditions = build_base_filter_conditions(
user_id=user_id,
accessible_owners=accessible_owners,
doc_type=doc_type,
modified_after=modified_after,
modified_before=modified_before,
)
# ACL pre-filter (design §11), opt-in via ACL_PREFILTER_ENABLED and OFF
# by default. Additive `must` condition — it can only narrow results,