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

Closes out the remaining nits flagged in the round-6 review.

Critical:
- _verify_files contract comment now enumerates all None-return cases
  (404 + malformed PROPFIND XML) and documents the false-eviction
  trade-off; self-healing via re-indexing recovers
- int(r.id) cast at the SemanticSearchResult boundary now raises a
  TypeError with explicit doc_type/value context instead of bubbling
  up as an opaque "Search failed: ..." McpError

Design observations:
- nc_semantic_search_answer docstring documents the per-note
  round-trip cost from the post-verification race guard
- News verification latency hint added to configuration.md
- SemanticSearchResponse exposes verified_count + dropped_count so
  short result pages on high-ghost-density indexes are
  distinguishable from genuine scarcity. verify_search_results now
  returns (kept, dropped_count); production caller and tests updated

Minor:
- Comment clarifies the .get() fallback in verify_search_results is
  defensive only (run_verifier always populates the entry)
- Eviction task-group guard narrowed from except Exception to
  except RuntimeError (the only documented failure mode of
  TaskGroup.start_soon on a closed group)
- Indexer logs a warning when a deck_card task is missing
  board_id/stack_id, surfacing data-quality issues at index time
  rather than at verification time
- New unit test covers the news verifier's non-numeric-id fail-open
  path (one bad doc_id keeps the entire batch)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-05-01 21:02:27 +02:00
co-authored by Claude Opus 4.7
parent ffcca23a7b
commit e8df6003c5
7 changed files with 159 additions and 29 deletions
+30 -10
View File
@@ -139,11 +139,25 @@ async def _verify_files(
try:
info = await client.webdav.get_file_info(file_path)
if info is None:
# Contract: WebDAVClient.get_file_info returns None on 404
# and raises HTTPStatusError on 403/5xx/network. If that
# contract changes (e.g. a future refactor that raises 404
# like other client methods), the `except HTTPStatusError`
# block below already handles it via _is_definitive_404_or_403.
# Contract: WebDAVClient.get_file_info returns None in two
# cases — (1) HTTP 404, and (2) a malformed PROPFIND XML
# response (missing <d:response>, <d:propstat>, or <d:prop>
# — see client/webdav.py). Both are treated as
# "inaccessible" and trigger eviction.
#
# Trade-off: a malformed response from a brittle backend
# could cause a *false* eviction. We accept that risk in
# exchange for correctness on real 404s — the index
# self-heals via re-indexing on the next scan, and
# malformed responses are exceedingly rare in practice.
# Distinguishing the two cases would require widening
# get_file_info's return contract; deferred to a future
# change if false evictions become observable.
#
# If the contract ever changes (e.g. 404 raises
# HTTPStatusError like other client methods), the
# `except HTTPStatusError` block below already handles
# it via _is_definitive_404_or_403.
return
accessible.add(doc_id)
except HTTPStatusError as e:
@@ -355,7 +369,7 @@ async def verify_search_results(
evict_on_missing: bool = True,
max_concurrent: int | None = None,
eviction_task_group: TaskGroup | None = None,
) -> list[SearchResult]:
) -> tuple[list[SearchResult], int]:
"""Filter search results to those the user can currently access.
Deduplicates by ``(doc_id, doc_type)`` before verifying, so multiple
@@ -386,10 +400,13 @@ async def verify_search_results(
from FastMCP tools.
Returns:
Filtered list preserving the original order.
Tuple of ``(kept_results, dropped_count)`` where ``kept_results`` is
the filtered list preserving the original order and ``dropped_count``
is the number of unique ``(doc_id, doc_type)`` pairs that failed
verification (ghost records).
"""
if not results:
return results
return results, 0
user_id: str = client.username
@@ -443,6 +460,9 @@ async def verify_search_results(
# Compute (doc_id, doc_type) pairs that failed verification
inaccessible: set[tuple[int | str, str]] = set()
for doc_type, id_to_result in by_type.items():
# The .get() default is defensive only — run_verifier always populates
# accessible_by_type[doc_type], either with the verifier's result or
# with all ids on verifier crash (fail-open).
accessible = accessible_by_type.get(doc_type, set(id_to_result.keys()))
for doc_id in id_to_result.keys():
if doc_id not in accessible:
@@ -492,11 +512,11 @@ async def verify_search_results(
# best-effort: the next query re-verifies and re-attempts.
try:
eviction_task_group.start_soon(evict, doc_id, doc_type)
except Exception:
except RuntimeError:
logger.debug("Eviction task group closed; will retry on next query")
else:
async with anyio.create_task_group() as tg:
for doc_id, doc_type in inaccessible:
tg.start_soon(evict, doc_id, doc_type)
return kept
return kept, len(inaccessible)