diff --git a/CHANGELOG.md b/CHANGELOG.md index af42356e..12980eb5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ All notable changes to vouch are documented here. Format follows ## [Unreleased] ### Fixed +- **`kb.search` excludes retracted claims and archived pages** (#581): + `search_kb` now drops `ARCHIVED` / `SUPERSEDED` / `REDACTED` claims and + `ARCHIVED` pages the same way `kb.context` already does, so lifecycle + controls are not decorative on the detail-search surface. backends + over-fetch a candidate pool before that filter so retracted top-hits + cannot starve the requested result limit. - **bench grading saw highlight markup**: retrieval wraps query-matched terms in guillemets, which broke the bench's substring checks exactly on query-relevant claims — expected values read as missing (deflating diff --git a/src/vouch/context.py b/src/vouch/context.py index f2e5e1d3..70edc392 100644 --- a/src/vouch/context.py +++ b/src/vouch/context.py @@ -22,7 +22,13 @@ from . import graph, hot_memory, index_db, retrieval_events from . import strategy as strategy_mod from .embeddings.fusion import rrf_fuse -from .models import ClaimStatus, ContextItem, ContextPack, ContextQuality +from .models import ( + ClaimStatus, + ContextItem, + ContextPack, + ContextQuality, + PageStatus, +) from .scoping import ( ViewerContext, filter_hits, @@ -51,6 +57,12 @@ _STRATEGY_POOL_FACTOR = 5 _STRATEGY_POOL_MIN = 50 +# same sizing for kb.search lifecycle filtering: backends cap before status +# filtering, so without over-fetch a window full of retracted hits under-fills +# the requested limit (#581 / coderabbit). +_LIFECYCLE_POOL_FACTOR = _STRATEGY_POOL_FACTOR +_LIFECYCLE_POOL_MIN = _STRATEGY_POOL_MIN + _VALID_BACKENDS = ("auto", "hybrid", "embedding", "fts5", "substring") _RERANKER_CACHE: Any | None = None @@ -469,6 +481,11 @@ def search_kb( agent=agent, ) fetch_limit = scoped_fetch_limit(limit, viewer) + # over-fetch before lifecycle filtering so retracted/archived hits that + # consume the backend window can be replaced by later live candidates. + candidate_limit = max( + fetch_limit * _LIFECYCLE_POOL_FACTOR, _LIFECYCLE_POOL_MIN, + ) hits: list[tuple[str, str, str, float]] = [] used = backend_arg @@ -481,13 +498,13 @@ def search_kb( if backend_arg in ("auto", "hybrid"): emb = index_db.search_semantic( - store.kb_dir, query, limit=fetch_limit * 2, min_score=min_score, + store.kb_dir, query, limit=candidate_limit * 2, min_score=min_score, ) try: - fts = index_db.search(store.kb_dir, query, limit=fetch_limit * 2) + fts = index_db.search(store.kb_dir, query, limit=candidate_limit * 2) except sqlite3.Error: fts = [] - hits = rrf_fuse(emb, fts, limit=fetch_limit) + hits = rrf_fuse(emb, fts, limit=candidate_limit) if emb and fts: used = "hybrid" elif emb: @@ -495,28 +512,31 @@ def search_kb( elif fts: used = "fts5" if not hits and backend_arg == "auto": - hits = store.search_substring(query, limit=fetch_limit) + hits = store.search_substring(query, limit=candidate_limit) used = "substring" elif backend_arg == "embedding": hits = index_db.search_semantic( - store.kb_dir, query, limit=fetch_limit, min_score=min_score, + store.kb_dir, query, limit=candidate_limit, min_score=min_score, ) used = "embedding" elif backend_arg == "fts5": try: - hits = index_db.search(store.kb_dir, query, limit=fetch_limit) + hits = index_db.search(store.kb_dir, query, limit=candidate_limit) except sqlite3.Error: hits = [] used = "fts5" else: # substring - hits = store.search_substring(query, limit=fetch_limit) + hits = store.search_substring(query, limit=candidate_limit) used = "substring" semantic_ok = index_db.semantic_search_available() - scoped = filter_hits(store, hits, viewer, limit=limit) + # scope first without a limit so status filtering can refill the window — + # otherwise a page of retracted hits would leave search under-filled. + scoped = filter_hits(store, hits, viewer, limit=None) + live = _filter_live_hits(store, scoped, limit=limit) hits_list = [ {"kind": k, "id": i, "snippet": sn, "score": sc, "backend": used} - for k, i, sn, sc in scoped + for k, i, sn, sc in live ] result: dict[str, Any] = { "backend": used, @@ -541,6 +561,40 @@ def search_kb( ) +def _filter_live_hits( + store: KBStore, + hits: list[tuple[str, str, str, float]], + *, + limit: int | None = None, +) -> list[tuple[str, str, str, float]]: + """Drop retracted claims and archived pages from search hits. + + ``kb.context`` already applies ``_RETRACTED_CLAIM_STATUSES``; ``kb.search`` + must do the same or archive/supersede/redact become decorative on the + surface agents use for detail after recall (#581). + """ + kept: list[tuple[str, str, str, float]] = [] + for kind, artifact_id, summary, score in hits: + if kind == "claim": + try: + claim = store.get_claim(artifact_id) + except ArtifactNotFoundError: + continue + if claim.status in _RETRACTED_CLAIM_STATUSES: + continue + elif kind == "page": + try: + page = store.get_page(artifact_id) + except ArtifactNotFoundError: + continue + if page.status is PageStatus.ARCHIVED: + continue + kept.append((kind, artifact_id, summary, score)) + if limit is not None and len(kept) >= limit: + break + return kept + + def _enrich_summary(store: KBStore, kind: str, artifact_id: str, summary: str) -> str: """Return a non-empty summary, falling back to the stored artifact text.""" if summary: diff --git a/tests/test_context.py b/tests/test_context.py index b515829e..049ea0b8 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -172,6 +172,111 @@ def test_context_pack_excludes_archived_claims(store: KBStore) -> None: assert not any(it["id"] == "c1" for it in pack["items"]), pack +def test_search_kb_excludes_retracted_claims(store: KBStore) -> None: + """Regression for #581: search_kb must apply the same retracted-status + filter as build_context_pack — otherwise kb.search leaks archived / + superseded / redacted claims after lifecycle controls run.""" + from vouch import lifecycle + from vouch.models import Page, PageStatus, PageType + + src = store.put_source(b"e") + store.put_claim(Claim( + id="c1", text="mongodb is faster than postgres", evidence=[src.id], + )) + store.put_page(Page( + id="p-live", title="mongodb ops", body="live page about mongodb", + type=PageType.CONCEPT, sources=[src.id], + )) + store.put_page(Page( + id="p-arch", title="mongodb old", body="archived mongodb notes", + type=PageType.CONCEPT, status=PageStatus.ARCHIVED, sources=[src.id], + )) + health.rebuild_index(store) + + before = context.search_kb(store, query="mongodb", backend="substring") + ids_before = {h["id"] for h in before["hits"]} + assert "c1" in ids_before, before + assert "p-live" in ids_before, before + assert "p-arch" not in ids_before, before + + lifecycle.archive(store, claim_id="c1", actor="reviewer") + after = context.search_kb(store, query="mongodb", backend="substring") + ids_after = {h["id"] for h in after["hits"]} + assert "c1" not in ids_after, after + assert "p-live" in ids_after, after + assert "p-arch" not in ids_after, after + + +@pytest.mark.parametrize("mode", ["archived", "superseded", "redacted"]) +def test_search_kb_excludes_each_retracted_status( + store: KBStore, mode: str, +) -> None: + """#581: each retracted claim status must disappear from kb.search.""" + from vouch import lifecycle + + src = store.put_source(b"e") + store.put_claim(Claim( + id="c1", text="mongodb is faster than postgres", evidence=[src.id], + )) + health.rebuild_index(store) + assert any( + h["id"] == "c1" + for h in context.search_kb( + store, query="mongodb", backend="substring", + )["hits"] + ) + + if mode == "archived": + lifecycle.archive(store, claim_id="c1", actor="reviewer") + elif mode == "superseded": + store.put_claim(Claim( + id="c2", text="mongodb is faster than postgres v2", + evidence=[src.id], + )) + lifecycle.supersede( + store, old_claim_id="c1", new_claim_id="c2", actor="reviewer", + ) + else: + lifecycle.redact(store, claim_id="c1", actor="reviewer") + + health.rebuild_index(store) + ids = { + h["id"] + for h in context.search_kb( + store, query="mongodb", backend="substring", + )["hits"] + } + assert "c1" not in ids, ids + + +def test_search_kb_refills_limit_after_lifecycle_filter(store: KBStore) -> None: + """#581: over-fetch so archived top-hits do not starve live results.""" + from vouch import lifecycle + + src = store.put_source(b"e") + # high substring score (many query matches) but retracted — would fill a + # tight backend window and hide the live claim without candidate over-fetch. + for i in range(5): + cid = f"arch-{i}" + store.put_claim(Claim( + id=cid, + text="mongodb mongodb mongodb mongodb mongodb", + evidence=[src.id], + )) + lifecycle.archive(store, claim_id=cid, actor="reviewer") + store.put_claim(Claim( + id="live-mongo", text="mongodb tip", evidence=[src.id], + )) + health.rebuild_index(store) + + result = context.search_kb( + store, query="mongodb", backend="substring", limit=1, + ) + ids = [h["id"] for h in result["hits"]] + assert ids == ["live-mongo"], result + assert len(result["hits"]) == 1 + + def test_context_pack_excludes_superseded_claims(store: KBStore) -> None: """Regression for #78: supersede(old, new) must keep `new` retrievable while removing `old` from kb.context."""