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
+33 -1
View File
@@ -39,7 +39,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,
@@ -141,6 +144,31 @@ async def vector_visualization_search(request: Request) -> JSONResponse:
doc_types_param = request.query_params.get("doc_types", "")
doc_types = doc_types_param.split(",") if doc_types_param else None
# Parse ADR-027 modified-date range filter. Accepts RFC 3339 / ISO 8601
# datetimes or Unix seconds; normalized to int Unix seconds. Absent ⇒
# open-ended. Unparseable input or an inverted range returns 400.
try:
modified_after = parse_modified_timestamp(
request.query_params.get("modified_after"), param_name="modified_after"
)
modified_before = parse_modified_timestamp(
request.query_params.get("modified_before"), param_name="modified_before"
)
except ValueError as exc:
return JSONResponse(
{"success": False, "error": str(exc)},
status_code=400,
)
if (
modified_after is not None
and modified_before is not None
and modified_after > modified_before
):
return JSONResponse(
{"success": False, "error": "modified_after must be <= modified_before"},
status_code=400,
)
logger.info(
"Viz search: user=%s, query='%s', algorithm=%s, fusion=%s, limit=%s, doc_types=%s",
username,
@@ -202,6 +230,8 @@ async def vector_visualization_search(request: Request) -> JSONResponse:
doc_type=None, # Search all types
score_threshold=score_threshold,
accessible_owners=accessible_owners,
modified_after=modified_after,
modified_before=modified_before,
)
all_results.extend(unverified_results)
else:
@@ -222,6 +252,8 @@ async def vector_visualization_search(request: Request) -> JSONResponse:
doc_type=doc_type,
score_threshold=score_threshold,
accessible_owners=accessible_owners,
modified_after=modified_after,
modified_before=modified_before,
)
all_results.extend(unverified_results)
# Sort by score, then cap to the same limit*2 over-fetch budget