diff --git a/CHANGELOG.md b/CHANGELOG.md index cd3dd6c1..5a7c8868 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -106,6 +106,20 @@ All notable changes to vouch are documented here. Format follows artifact the caller could not already retrieve, and it touches no write path. ### Fixed +- **`kb.experts` no longer leaks out-of-scope claims into entity rankings** + (#714): `rank_experts` aggregated evidence density over every claim in + the KB with no viewer/scope filtering at all, unlike every sibling + claim-aggregating read surface (`context.py`, `graph.py`, `digest.py`, + `health.py`, `compile.py`, and `themes.detect_themes`, the closest + shape-wise sibling). A `project`- or `agent`-scoped claim the caller + cannot otherwise retrieve still inflated `claim_count`, `citation_count`, + and `score`, and could surface verbatim in `top_claim_ids` — handing the + caller a claim id it cannot fetch. `rank_experts` now takes an optional + `viewer` (defaulting to `scoping.viewer_from(...)`, matching + `detect_themes`) and filters through `scoping.is_visible` before a claim + can contribute anything, with the FTS candidate fetch run through + `scoping.scoped_fetch_limit` so a mostly-out-of-scope KB doesn't starve + the candidate pool before the filter runs. - **`vouch render-wiki` drops archived pages** (#695): `render_wiki_cmd` passed every on-disk page into index/MOC, so retired titles kept wiki links after archive. the CLI now filters to the same diff --git a/src/vouch/experts.py b/src/vouch/experts.py index c20f409e..00ac6a26 100644 --- a/src/vouch/experts.py +++ b/src/vouch/experts.py @@ -15,6 +15,7 @@ from . import index_db from .models import Claim, ClaimStatus, utcnow from .salience import _substring_entity_ids +from .scoping import ViewerContext, is_visible, scoped_fetch_limit, viewer_from from .storage import KBStore # A superseded / archived / redacted claim is not live evidence and must never @@ -44,24 +45,37 @@ def rank_experts( limit: int = 10, min_claims: int = 1, weight: str = "count", + viewer: ViewerContext | None = None, ) -> list[dict[str, Any]]: """Return entities ranked by evidence density on ``topic``. ``weight`` is one of ``count`` | ``recency`` | ``citation``; an unknown value falls back to ``count`` (never raises), matching the defensive-config style used elsewhere. Ordered by descending score with a stable tie-break - on ``entity_id``. + on ``entity_id``. ``viewer`` defaults to the config-resolved context, so a + KB read with no explicit viewer reads as its own project (see + ``scoping.viewer_from``) — matching ``themes.detect_themes``, the sibling + read surface this mirrors. Claims the viewer cannot retrieve never + contribute to ``claim_count``, ``citation_count``, or ``score``, and never + appear in ``top_claim_ids``: scoring after filtering, not just scrubbing + the id list, is what keeps a mostly-private entity from outranking one + the viewer can actually read. """ if weight not in _VALID_WEIGHTS: weight = "count" + if viewer is None: + viewer = viewer_from(config_path=store.config_path) entities = store.list_entities() by_id = {ent.id: ent for ent in entities} topic_entity_ids = set(_substring_entity_ids(entities, topic)) # Candidate claims: FTS hits on the topic, plus every claim that references - # an entity whose name/alias matches the topic. - fetch = max(limit * 5, 50) + # an entity whose name/alias matches the topic. Over-fetched via + # scoped_fetch_limit so a viewer-scoped KB whose top FTS hits are mostly + # private doesn't starve the candidate pool before the scope filter below + # ever runs. + fetch = scoped_fetch_limit(max(limit * 5, 50), viewer) fts_claim_ids = { cid for kind, cid, _snip, _score in index_db.search(store.kb_dir, topic, limit=fetch) @@ -77,6 +91,8 @@ def rank_experts( for claim in store.list_claims(): if claim.status in _EXCLUDED_STATUSES: continue + if not is_visible(claim.scope, viewer): + continue matched = claim.id in fts_claim_ids or bool( set(claim.entities) & topic_entity_ids ) diff --git a/tests/test_experts.py b/tests/test_experts.py index 0e3536e2..f17366ac 100644 --- a/tests/test_experts.py +++ b/tests/test_experts.py @@ -14,7 +14,8 @@ from vouch.experts import rank_experts from vouch.jsonl_server import handle_request -from vouch.models import Claim, ClaimStatus, Entity, EntityType +from vouch.models import ArtifactScope, Claim, ClaimStatus, Entity, EntityType, Visibility +from vouch.scoping import ViewerContext from vouch.storage import KBStore @@ -127,6 +128,51 @@ def test_deterministic_tie_break_on_entity_id(store: KBStore) -> None: assert tied == ["a1", "a2"] # equal score -> ascending entity_id +def test_scopes_ranking_to_the_viewer(store: KBStore) -> None: + """A viewer outside a claim's project must not have that claim inflate + claim_count/citation_count/score, and must never see its id surface in + top_claim_ids — scoring after filtering, not just scrubbing the id list, + is what keeps a mostly-private entity from outranking one the viewer can + actually read.""" + src = store.put_source(b"y") + store.put_entity(Entity(id="jwt", name="JWT", type=EntityType.CONCEPT)) + store.put_entity(Entity(id="alice", name="alice", type=EntityType.PERSON)) + for i in range(3): + store.put_claim( + Claim( + id=f"priv{i}", + text=f"jwt fact {i} by alice", + evidence=[src.id], + entities=["jwt", "alice"], + scope=ArtifactScope(visibility=Visibility.PROJECT, project="secret-project"), + ) + ) + store.put_claim( + Claim( + id="pub", + text="jwt public fact by alice", + evidence=[src.id], + entities=["jwt", "alice"], + scope=ArtifactScope(visibility=Visibility.PUBLIC), + ) + ) + + foreign = ViewerContext(project="other-project", agent=None) + rows = rank_experts(store, "JWT", viewer=foreign) + alice = next(r for r in rows if r["entity_id"] == "alice") + assert alice["claim_count"] == 1 + assert alice["top_claim_ids"] == ["pub"] + for row in rows: + assert "priv0" not in row["top_claim_ids"] + assert "priv1" not in row["top_claim_ids"] + assert "priv2" not in row["top_claim_ids"] + + owner = ViewerContext(project="secret-project", agent=None) + owner_rows = rank_experts(store, "JWT", viewer=owner) + owner_alice = next(r for r in owner_rows if r["entity_id"] == "alice") + assert owner_alice["claim_count"] == 4 + + def test_jsonl_experts_envelope_success(store: KBStore, monkeypatch) -> None: # kb.experts over the JSONL contract: a well-formed request returns the # {id, ok, result} envelope with the ranking under result["experts"].