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
+52 -10
View File
@@ -266,6 +266,33 @@ async def test_verify_news_items_transient_keeps_all(mocker):
assert result == {1, 2, 3}
@pytest.mark.unit
async def test_verify_news_items_non_numeric_id_keeps_all(mocker):
"""One non-numeric doc_id triggers fail-open for the WHOLE result set.
The intersection logic in `_verify_news_items` tries `int(d)` for each
incoming doc_id; a single non-numeric value (e.g. ``"abc"``) raises
ValueError inside the intersection loop. The except block must catch it
and return the full input set (fail open) rather than dropping anything.
This is intentional v1 behaviour — surfacing one bad id should not
drop legitimate adjacent results.
"""
news_client = SimpleNamespace(
get_items=mocker.AsyncMock(return_value=[{"id": 10}, {"id": 20}])
)
client = SimpleNamespace(news=news_client, username="alice")
doc_ids: list[int | str] = [10, 20, "abc"]
result = await _verify_news_items(
client,
[_make_result(d, doc_type="news_item") for d in doc_ids],
_sem(),
)
# Fail-open: every requested id is preserved, INCLUDING the bad one.
assert result == {10, 20, "abc"}
# ---------------------------------------------------------------------------
# File verifier
# ---------------------------------------------------------------------------
@@ -449,7 +476,7 @@ async def test_verify_deck_cards_missing_metadata_keeps_unverified(mocker):
@pytest.mark.unit
async def test_verify_search_results_empty_input_passthrough():
client = SimpleNamespace(username="alice")
assert await verify_search_results(client, []) == []
assert await verify_search_results(client, []) == ([], 0)
@pytest.mark.unit
@@ -466,9 +493,10 @@ async def test_verify_search_results_dedupes_chunks_per_document(mocker):
]
client = SimpleNamespace(username="alice")
kept = await verify_search_results(client, results)
kept, dropped_count = await verify_search_results(client, results)
assert len(kept) == 3 # all kept, all reference the same accessible doc
assert dropped_count == 0
spy.assert_awaited_once()
# Verifier received exactly one SearchResult (the deduplicated representative)
args, _kwargs = spy.call_args
@@ -494,9 +522,10 @@ async def test_verify_search_results_drops_inaccessible_and_evicts(mocker):
]
client = SimpleNamespace(username="alice")
kept = await verify_search_results(client, results)
kept, dropped_count = await verify_search_results(client, results)
assert [r.id for r in kept] == [1]
assert dropped_count == 1
spy_evict.assert_awaited_once_with(99, "note", "alice")
@@ -530,9 +559,12 @@ async def test_verify_search_results_fire_and_forget_eviction(mocker):
client = SimpleNamespace(username="alice")
async with anyio.create_task_group() as tg:
kept = await verify_search_results(client, results, eviction_task_group=tg)
kept, dropped_count = await verify_search_results(
client, results, eviction_task_group=tg
)
# 1. Search response was returned …
assert kept == []
assert dropped_count == 1
# 2. … even though eviction has started but not finished.
await eviction_started.wait()
assert not eviction_completed.is_set()
@@ -554,9 +586,12 @@ async def test_verify_search_results_no_eviction_when_disabled(mocker):
results = [_make_result(7, doc_type="note")]
client = SimpleNamespace(username="alice")
kept = await verify_search_results(client, results, evict_on_missing=False)
kept, dropped_count = await verify_search_results(
client, results, evict_on_missing=False
)
assert kept == []
assert dropped_count == 1
spy_evict.assert_not_awaited()
@@ -575,9 +610,10 @@ async def test_verify_search_results_unknown_doc_type_passes_through(mocker, cap
results = [_make_result(1, doc_type="calendar")]
client = SimpleNamespace(username="alice")
kept = await verify_search_results(client, results)
kept, dropped_count = await verify_search_results(client, results)
assert len(kept) == 1
assert dropped_count == 0
spy_evict.assert_not_awaited()
@@ -595,9 +631,10 @@ async def test_verify_search_results_verifier_blowup_keeps_all(mocker):
]
client = SimpleNamespace(username="alice")
kept = await verify_search_results(client, results)
kept, dropped_count = await verify_search_results(client, results)
assert [r.id for r in kept] == [1, 2]
assert dropped_count == 0 # fail-open: nothing dropped
spy_evict.assert_not_awaited()
@@ -615,9 +652,10 @@ async def test_verify_search_results_preserves_order(mocker):
]
client = SimpleNamespace(username="alice")
kept = await verify_search_results(client, results)
kept, dropped_count = await verify_search_results(client, results)
assert [r.id for r in kept] == [1, 3]
assert dropped_count == 1
@pytest.mark.unit
@@ -633,8 +671,11 @@ async def test_verify_search_results_eviction_failure_does_not_propagate(mocker)
client = SimpleNamespace(username="alice")
# Should NOT raise
kept = await verify_search_results(client, [_make_result(1, doc_type="note")])
kept, dropped_count = await verify_search_results(
client, [_make_result(1, doc_type="note")]
)
assert kept == []
assert dropped_count == 1
@pytest.mark.unit
@@ -656,9 +697,10 @@ async def test_verify_search_results_dispatches_per_doc_type_concurrently(mocker
]
client = SimpleNamespace(username="alice")
kept = await verify_search_results(client, results)
kept, dropped_count = await verify_search_results(client, results)
assert {(r.id, r.doc_type) for r in kept} == {(1, "note"), (500, "file")}
assert dropped_count == 1
note_verifier.assert_awaited_once()
file_verifier.assert_awaited_once()