refactor(search): address PR #750 round 3 review feedback

- _verify_deck_cards: hoist int(board_id|stack_id|doc_id) out of the generic
  except Exception into an explicit try/except (TypeError, ValueError) before
  the network call, mirroring _verify_news_items. Malformed payloads now log
  a specific warning instead of "unexpected error".
- _verify_news_items: add TODO(perf) above the get_items(batch_size=-1) call
  to mark the known fetch-all cost as a future profiling target.
- SemanticSearchResult.id: revert from int|str back to int. The internal
  SearchResult.id stays int|str for forward-compat; the MCP response model
  narrows at the boundary. server/semantic.py casts r.id to int when
  constructing the response so future string-id types fail loudly here
  instead of silently widening the public API.
- nc_semantic_search: replace the terse "extra for access filtering" comment
  with an ADR-019 NOTE block explaining the 2x over-fetch trade-off and the
  ghost-density under-delivery case (self-heals via lazy eviction).
- tests/integration/test_verify_on_read.py: extend the module docstring to
  call out that only the note verifier is exercised against real Nextcloud,
  while file/deck_card/news_item are unit-only — documenting the suite split
  for future contributors.
- ADR-019: rewrite "Module shape", "Verifier registry", example verifier,
  and "Deduplication" sections to match the shipped BatchVerifier interface
  (was per-id Verifier in the original draft). Add a "Why batch?" paragraph
  explaining the design choice. Update implementation checklist — every
  item is now [x] with corrected verifier names (plural) and the eviction
  module path (vector/eviction.py).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-05-01 19:17:35 +02:00
co-authored by Claude Opus 4.7
parent 21e5608a39
commit aa4b9498a1
5 changed files with 150 additions and 47 deletions
@@ -73,16 +73,24 @@ The vector index becomes a **hint**, not a contract. We never trust it for acces
```python
# nextcloud_mcp_server/search/verification.py
from typing import Awaitable, Callable, Protocol
from typing import Awaitable, Callable
import anyio
import httpx
from nextcloud_mcp_server.search.algorithms import SearchResult
# A verifier returns True if the document is currently accessible to the user.
# It MUST distinguish definitive 404/403 (return False) from transient errors
# (raise — caller will keep the result and log a warning).
Verifier = Callable[["NextcloudClientProtocol", int | str], Awaitable[bool]]
# A batch verifier takes a list of results for a single doc_type and returns
# the set of doc_ids that are currently accessible to the user. The shared
# semaphore caps concurrent Nextcloud round-trips across all verifier types.
#
# - Definitive 403/404 → omit the id from the returned set (drop the result).
# - Transient error (5xx, network, parse) → include the id (fail-open keep).
# - Verifier crash → caught by the dispatcher and treated as transient
# (all results for that type are kept; logged distinctly).
BatchVerifier = Callable[
["NextcloudClientProtocol", list[SearchResult], anyio.Semaphore],
Awaitable[set[int | str]],
]
async def verify_search_results(
@@ -91,56 +99,98 @@ async def verify_search_results(
*,
max_concurrent: int = 20,
evict_on_missing: bool = True,
eviction_task_group: anyio.abc.TaskGroup | None = None,
) -> list[SearchResult]:
"""Filter search results to those the user can currently access.
Deduplicates by (doc_id, doc_type) before verifying, so multiple chunks
from the same document cost a single check. Verifies concurrently under
a semaphore. Drops results whose verifier returned False; keeps results
whose verifier raised (transient failure should not produce silent gaps).
from the same document cost a single check. Each verifier owns its own
concurrency under the shared semaphore. Drops results whose verifier
omitted them; keeps results whose verifier raised or whose doc_type has
no registered verifier (transient failure should not silently shrink
results).
When evict_on_missing=True, schedules async deletion of the Qdrant points
for the missing document(s) so subsequent queries don't re-pay the cost.
Pass ``eviction_task_group`` (typically the lifespan-owned background task
group) to make eviction fire-and-forget; without it we run a local task
group that blocks the response until evictions complete.
"""
```
**Why batch?** A per-id `Verifier` would force one task-group creation per id, multiply the number of small tasks, and prevent the news single-fetch optimization (the News API has no per-item endpoint, so per-id verification would be O(N × all_items)). The batch interface lets each verifier own its own concurrency strategy: notes/files/deck cards parallelize per id under the shared semaphore; news fetches once and intersects.
### Verifier registry
```python
_VERIFIERS: dict[str, Verifier] = {
"note": _verify_note,
"news_item": _verify_news_item,
"file": _verify_file,
"deck_card": _verify_deck_card,
_VERIFIERS: dict[str, BatchVerifier] = {
"note": _verify_notes,
"news_item": _verify_news_items,
"file": _verify_files,
"deck_card": _verify_deck_cards,
}
```
Each verifier follows the same pattern:
Each verifier follows the same shape — accept a list of results for its type, fan out per-id under the shared semaphore (or fetch once and intersect, for news), and return the set of accessible ids:
```python
async def _verify_note(client, doc_id: int) -> bool:
try:
await client.notes.get_note(int(doc_id))
return True
except httpx.HTTPStatusError as e:
if e.response.status_code in (403, 404):
return False
raise # transient — caller keeps the result
async def _verify_notes(
client,
results: list[SearchResult],
semaphore: anyio.Semaphore,
) -> set[int | str]:
accessible: set[int | str] = set()
async def check(result: SearchResult) -> None:
async with semaphore:
try:
await client.notes.get_note(int(result.id))
accessible.add(result.id)
except httpx.HTTPStatusError as e:
if e.response.status_code in (403, 404):
return # definitive — drop
accessible.add(result.id) # transient — keep
async with anyio.create_task_group() as tg:
for r in results:
tg.start_soon(check, r)
return accessible
```
For `file`, use `webdav` PROPFIND (`Depth: 0`) on the `file_path` from the Qdrant payload, not `read_file()`. For `deck_card`, use the cached `(board_id, stack_id)` from `_get_deck_metadata_from_qdrant`; if metadata is absent, treat the result as accessible and log — we will not run the iteration fallback in the hot path.
For `file`, use WebDAV PROPFIND (`Depth: 0`) on the `file_path` from the Qdrant payload, not `read_file()`. For `deck_card`, use the cached `(board_id, stack_id)` from `_get_deck_metadata_from_qdrant`; if metadata is absent, treat the result as accessible and log — we will not run the iteration fallback in the hot path. For `news_item`, batch-fetch the user's items once via `get_items(batch_size=-1)` and intersect, since the News API has no per-item endpoint.
### Deduplication
A 10-result page typically references 34 unique documents because of chunking. Verify each unique `(doc_id, doc_type)` once, then propagate the verdict to all chunks of that document:
A 10-result page typically references 34 unique documents because of chunking. Dedupe by `(doc_id, doc_type)` *before* invoking the verifiers, so each batch verifier sees only unique ids. The dispatcher then propagates each id's verdict to every chunk of that document:
```python
unique_keys = {(r.id, r.doc_type) for r in results}
verdicts = {key: await _verify(client, key) for key in unique_keys} # via task group
return [r for r in results if verdicts.get((r.id, r.doc_type), True)]
unique: list[SearchResult] = []
seen: set[tuple[int | str, str]] = set()
for r in results:
key = (r.id, r.doc_type)
if key not in seen:
seen.add(key)
unique.append(r)
# Group unique results by doc_type and run their batch verifiers in parallel.
by_type: dict[str, list[SearchResult]] = group_by_doc_type(unique)
accessible_by_type: dict[str, set[int | str]] = {}
async with anyio.create_task_group() as tg:
for dtype, items in by_type.items():
verifier = _VERIFIERS.get(dtype)
if verifier is None:
# Soft failure: keep all results for unknown doc_types.
accessible_by_type[dtype] = {r.id for r in items}
continue
tg.start_soon(_run_verifier, verifier, dtype, items, accessible_by_type)
return [
r for r in results
if r.id in accessible_by_type.get(r.doc_type, set())
]
```
A failed verification (raised exception) maps to "keep" — we do not want a flaky network blip to silently shrink results.
A verifier crash maps to "keep all" for that type — we do not want a flaky network blip to silently shrink results.
### Lazy eviction
@@ -217,13 +267,13 @@ In `server/semantic.py::nc_semantic_search_answer`, replace the per-type `if res
## Implementation Checklist
- [ ] Create `nextcloud_mcp_server/search/verification.py` with `verify_search_results()` and the verifier registry.
- [ ] Implement `_verify_note`, `_verify_news_item`, `_verify_file` (PROPFIND), `_verify_deck_card` (metadata fast-path only).
- [ ] Add `delete_document_points()` in `vector/placeholder.py` (or a new `vector/eviction.py`) for non-placeholder filter-based deletes.
- [ ] Wire into `nc_semantic_search` with `limit * 2` over-fetch, trim to `limit` after verification.
- [ ] Wire into `nc_semantic_search_answer`, replacing the per-type note branch.
- [ ] Update existing docstrings in `search/semantic.py:52` and `search/bm25_hybrid.py:75` to point at the new helper.
- [ ] Unit tests: each verifier handles 200/403/404/transient distinctly; dedup collapses chunks; eviction is scheduled on `False`.
- [ ] Integration test: index a note, delete via API (no webhook), confirm the next semantic search does not return it.
- [x] Create `nextcloud_mcp_server/search/verification.py` with `verify_search_results()` and the verifier registry.
- [x] Implement `_verify_notes`, `_verify_news_items`, `_verify_files` (PROPFIND), `_verify_deck_cards` (metadata fast-path only). Names plural to reflect the batch-verifier interface (see "Module shape" above).
- [x] Add `delete_document_points()` in `nextcloud_mcp_server/vector/eviction.py` for non-placeholder filter-based deletes.
- [x] Wire into `nc_semantic_search` with `limit * 2` over-fetch, trim to `limit` after verification.
- [x] Wire into `nc_semantic_search_answer`; verification runs upstream in `nc_semantic_search`, and the note-only re-fetch is retained as a sub-second race guard.
- [x] Update existing docstrings in `search/semantic.py` and `search/bm25_hybrid.py` to reflect the new verify-on-read path.
- [x] Unit tests: each verifier handles 200/403/404/transient distinctly; dedup collapses chunks; eviction is scheduled on missing.
- [x] Integration test: index a note, delete via API (no webhook), confirm the next semantic search does not return it. (See `tests/integration/test_verify_on_read.py`. Coverage gap for `file`, `deck_card`, `news_item` integration tests is tracked as a follow-up.)
- [x] CI guard: enumerate indexed doc_types in `vector/scanner.py` and assert each has a registered verifier. (`INDEXED_DOC_TYPES` in `vector/scanner.py`; `tests/unit/search/test_verification.py::test_supported_doc_types_covers_indexed_types`.)
- [x] Document the latency budget and rate-limit posture in `docs/configuration.md`. (See "Verify-on-Read Latency Budget" section.)