Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
74 changes: 64 additions & 10 deletions src/vouch/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand All @@ -481,42 +498,45 @@ 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:
used = "embedding"
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,
Expand All @@ -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:
Expand Down
105 changes: 105 additions & 0 deletions tests/test_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
Loading