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:
co-authored by
Claude Opus 4.7
parent
ffcca23a7b
commit
e8df6003c5
@@ -80,6 +80,22 @@ class SemanticSearchResponse(BaseResponse):
|
||||
search_method: str = Field(
|
||||
default="semantic", description="Search method used (semantic or hybrid)"
|
||||
)
|
||||
verified_count: int = Field(
|
||||
default=0,
|
||||
description=(
|
||||
"Number of unique documents that passed verify-on-read access "
|
||||
"checks (ADR-019). Equals len(results) before trimming to limit."
|
||||
),
|
||||
)
|
||||
dropped_count: int = Field(
|
||||
default=0,
|
||||
description=(
|
||||
"Number of unique documents dropped as ghost records during "
|
||||
"verify-on-read (ADR-019). A short result page (len(results) < "
|
||||
"limit) combined with a non-zero dropped_count indicates ghost "
|
||||
"density rather than scarcity of relevant content."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class SamplingSearchResponse(BaseResponse):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -172,11 +172,12 @@ def configure_semantic_tools(mcp: FastMCP):
|
||||
eviction_task_group = getattr(
|
||||
ctx.request_context.lifespan_context, "eviction_task_group", None
|
||||
)
|
||||
verified_results = await verify_search_results(
|
||||
verified_results, dropped_count = await verify_search_results(
|
||||
client,
|
||||
all_results,
|
||||
eviction_task_group=eviction_task_group,
|
||||
)
|
||||
verified_count = len(verified_results)
|
||||
search_results = verified_results[:limit]
|
||||
|
||||
# Convert SearchResult objects to SemanticSearchResult for response.
|
||||
@@ -188,9 +189,24 @@ def configure_semantic_tools(mcp: FastMCP):
|
||||
# public API.
|
||||
results = []
|
||||
for r in search_results:
|
||||
try:
|
||||
narrowed_id = int(r.id)
|
||||
except (TypeError, ValueError) as e:
|
||||
# Re-raise with explicit context so the outer handler logs
|
||||
# something operators can act on (the generic "Search
|
||||
# failed: invalid literal for int()" is opaque).
|
||||
raise TypeError(
|
||||
f"SemanticSearchResult.id must be int-convertible, "
|
||||
f"got {r.id!r} (type={type(r.id).__name__}) for "
|
||||
f"doc_type={r.doc_type!r}. This indicates a doc_type "
|
||||
f"with non-numeric ids has been indexed but the "
|
||||
f"public response model has not been widened. Add "
|
||||
f"the doc_type to the SemanticSearchResult.id type "
|
||||
f"or convert at the verifier layer."
|
||||
) from e
|
||||
results.append(
|
||||
SemanticSearchResult(
|
||||
id=int(r.id),
|
||||
id=narrowed_id,
|
||||
doc_type=r.doc_type,
|
||||
title=r.title,
|
||||
category=r.metadata.get("category", "") if r.metadata else "",
|
||||
@@ -304,6 +320,8 @@ def configure_semantic_tools(mcp: FastMCP):
|
||||
query=query,
|
||||
total_found=len(results),
|
||||
search_method=f"bm25_hybrid_{fusion}",
|
||||
verified_count=verified_count,
|
||||
dropped_count=dropped_count,
|
||||
)
|
||||
|
||||
except ValueError as e:
|
||||
@@ -382,6 +400,14 @@ def configure_semantic_tools(mcp: FastMCP):
|
||||
Note: Requires MCP client to support sampling. If sampling is unavailable,
|
||||
the tool gracefully degrades to returning documents with an explanation.
|
||||
The client may prompt the user to approve the sampling request.
|
||||
|
||||
Latency profile: For each note in the result page, this tool fetches
|
||||
the full note body via ``client.notes.get_note`` after upstream
|
||||
verify-on-read has already round-tripped to the same endpoint as a
|
||||
race guard (ADR-019). Expect one additional Nextcloud round-trip per
|
||||
note result; raising ``limit`` above the default of 5 amplifies this
|
||||
cost roughly linearly. File / news / deck results do not pay this
|
||||
cost — they reuse the verified excerpt.
|
||||
"""
|
||||
# 1. Retrieve relevant documents via existing semantic search
|
||||
search_response = await nc_semantic_search(
|
||||
|
||||
@@ -658,6 +658,23 @@ async def _index_document(
|
||||
indexed_at = int(time.time())
|
||||
points = []
|
||||
|
||||
# Surface deck card data quality issues at indexing time rather than
|
||||
# only at verification time (where _verify_deck_cards falls through to
|
||||
# legacy-data pass-through when board_id/stack_id are missing). This is
|
||||
# logged once per document — not per chunk — to avoid log spam.
|
||||
if doc_task.doc_type == "deck_card":
|
||||
missing_deck_fields = [
|
||||
field for field in ("board_id", "stack_id") if not file_metadata.get(field)
|
||||
]
|
||||
if missing_deck_fields:
|
||||
logger.warning(
|
||||
"Indexing deck_card %s for user %s with missing metadata: %s; "
|
||||
"verification will fall back to legacy-data pass-through",
|
||||
doc_task.doc_id,
|
||||
doc_task.user_id,
|
||||
missing_deck_fields,
|
||||
)
|
||||
|
||||
for i, (chunk, dense_emb, sparse_emb) in enumerate(
|
||||
zip(chunks, dense_embeddings, sparse_embeddings)
|
||||
):
|
||||
|
||||
Reference in New Issue
Block a user