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
+49 -1
View File
@@ -37,7 +37,10 @@ from nextcloud_mcp_server.search.context import (
get_chunk_with_context,
)
from nextcloud_mcp_server.search.verification import verify_search_results
from nextcloud_mcp_server.utils.validation import is_valid_nextcloud_doc_id
from nextcloud_mcp_server.utils.validation import (
is_valid_nextcloud_doc_id,
parse_modified_timestamp,
)
from nextcloud_mcp_server.vector.oauth_sync import (
NotProvisionedError,
get_user_client_basic_auth,
@@ -198,6 +201,22 @@ async def unified_search(request: Request) -> JSONResponse:
1.0,
"score_threshold",
)
# ADR-027 modified-date range filter. Accepts RFC 3339 / ISO 8601
# datetimes or Unix seconds; normalized to int Unix seconds for the
# numeric Range filter. Absent bound ⇒ open-ended.
modified_after = parse_modified_timestamp(
body.get("modified_after"), param_name="modified_after"
)
modified_before = parse_modified_timestamp(
body.get("modified_before"), param_name="modified_before"
)
if (
modified_after is not None
and modified_before is not None
and modified_after > modified_before
):
raise ValueError("modified_after must be <= modified_before")
except ValueError as e:
return JSONResponse({"error": str(e)}, status_code=400)
@@ -245,6 +264,8 @@ async def unified_search(request: Request) -> JSONResponse:
limit=search_limit,
doc_type=doc_type,
accessible_owners=owners,
modified_after=modified_after,
modified_before=modified_before,
)
)
# Sort, then cap to a fixed over-fetch budget before the result
@@ -262,6 +283,8 @@ async def unified_search(request: Request) -> JSONResponse:
user_id=user_id,
limit=search_limit,
accessible_owners=owners,
modified_after=modified_after,
modified_before=modified_before,
)
return results
@@ -413,6 +436,17 @@ 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 modified-date range filter. Accepts RFC 3339 / ISO 8601
# datetimes or Unix seconds; normalized to int Unix seconds. None ⇒ open.
try:
modified_after = parse_modified_timestamp(
body.get("modified_after"), param_name="modified_after"
)
modified_before = parse_modified_timestamp(
body.get("modified_before"), param_name="modified_before"
)
except ValueError as e:
return JSONResponse({"error": str(e)}, status_code=400)
if not query:
return JSONResponse(
@@ -420,6 +454,16 @@ async def vector_search(request: Request) -> JSONResponse:
status_code=400,
)
if (
modified_after is not None
and modified_before is not None
and modified_after > modified_before
):
return JSONResponse(
{"error": "modified_after must be <= modified_before"},
status_code=400,
)
# Validate algorithm
valid_algorithms = {"semantic", "bm25", "hybrid"}
if algorithm not in valid_algorithms:
@@ -455,6 +499,8 @@ async def vector_search(request: Request) -> JSONResponse:
limit=limit,
doc_type=doc_type,
accessible_owners=owners,
modified_after=modified_after,
modified_before=modified_before,
)
)
# Sort merged results by score and limit
@@ -467,6 +513,8 @@ async def vector_search(request: Request) -> JSONResponse:
user_id=user_id,
limit=limit,
accessible_owners=owners,
modified_after=modified_after,
modified_before=modified_before,
)
return results