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
5 changes: 5 additions & 0 deletions schemas/capabilities.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@
"title": "Review Gated",
"type": "boolean"
},
"scoping": {
"additionalProperties": true,
"title": "Scoping",
"type": "object"
},
"spec": {
"default": "akbp-0.1-compatible",
"title": "Spec",
Expand Down
43 changes: 39 additions & 4 deletions schemas/claim.schema.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,40 @@
{
"$defs": {
"ArtifactScope": {
"description": "Structured scope: visibility tier plus optional project/agent binding.",
"properties": {
"agent": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Agent"
},
"project": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Project"
},
"visibility": {
"$ref": "#/$defs/Visibility",
"default": "project"
}
},
"title": "ArtifactScope",
"type": "object"
},
"ClaimStatus": {
"enum": [
"working",
Expand All @@ -26,14 +61,15 @@
"title": "ClaimType",
"type": "string"
},
"Scope": {
"Visibility": {
"description": "How widely an artifact is visible within retrieval surfaces.",
"enum": [
"private",
"project",
"team",
"public"
],
"title": "Scope",
"title": "Visibility",
"type": "string"
}
},
Expand Down Expand Up @@ -106,8 +142,7 @@
"title": "Last Confirmed At"
},
"scope": {
"$ref": "#/$defs/Scope",
"default": "project"
"$ref": "#/$defs/ArtifactScope"
},
"status": {
"$ref": "#/$defs/ClaimStatus",
Expand Down
57 changes: 46 additions & 11 deletions schemas/source.schema.json
Original file line number Diff line number Diff line change
@@ -1,14 +1,39 @@
{
"$defs": {
"Scope": {
"enum": [
"private",
"project",
"team",
"public"
],
"title": "Scope",
"type": "string"
"ArtifactScope": {
"description": "Structured scope: visibility tier plus optional project/agent binding.",
"properties": {
"agent": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Agent"
},
"project": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Project"
},
"visibility": {
"$ref": "#/$defs/Visibility",
"default": "project"
}
},
"title": "ArtifactScope",
"type": "object"
},
"SourceType": {
"enum": [
Expand All @@ -26,6 +51,17 @@
],
"title": "SourceType",
"type": "string"
},
"Visibility": {
"description": "How widely an artifact is visible within retrieval surfaces.",
"enum": [
"private",
"project",
"team",
"public"
],
"title": "Visibility",
"type": "string"
}
},
"$id": "https://vouch.dev/schemas/source.schema.json",
Expand Down Expand Up @@ -81,8 +117,7 @@
"type": "object"
},
"scope": {
"$ref": "#/$defs/Scope",
"default": "project"
"$ref": "#/$defs/ArtifactScope"
},
"tags": {
"items": {
Expand Down
6 changes: 6 additions & 0 deletions src/vouch/capabilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,4 +82,10 @@ def capabilities() -> Capabilities:
retrieval=retrieval,
review_gated=True,
transports=["mcp", "jsonl", "http"],
scoping={
"enabled": True,
"viewer_params": ["project", "agent"],
"env_vars": ["VOUCH_PROJECT", "VOUCH_AGENT"],
"config_path": "retrieval.scope",
},
)
51 changes: 44 additions & 7 deletions src/vouch/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -1216,6 +1216,9 @@ def crystallize(session_id: str, no_page: bool) -> None:
@click.option("--rerank/--no-rerank", default=False)
@click.option("--hyde/--no-hyde", default=False)
@click.option("--explain/--no-explain", default=False)
@click.option("--json", "as_json", is_flag=True, help="Emit hits as JSON.")
@click.option("--project", default=None, help="Viewer project for scope filtering.")
@click.option("--agent", default=None, help="Viewer agent for scope filtering.")
def search(
query: str,
limit: int,
Expand All @@ -1226,12 +1229,22 @@ def search(
rerank: bool,
hyde: bool,
explain: bool,
as_json: bool,
project: str | None,
agent: str | None,
) -> None:
"""Search the KB."""
from . import index_db
from .embeddings.fusion import rrf_fuse
from .scoping import filter_hits, scoped_fetch_limit, viewer_from

store = _load_store()
viewer = viewer_from(
config_path=store.config_path,
project=project,
agent=agent,
)
fetch_limit = scoped_fetch_limit(limit, viewer)
if top_k is not None:
limit = top_k
if semantic is True:
Expand All @@ -1250,22 +1263,24 @@ def search(
hits = index_db.search_semantic(
store.kb_dir,
q,
limit=limit,
limit=fetch_limit,
min_score=min_score,
)
used = "embedding" if hits else used
if not hits and backend in ("auto", "fts5"):
hits = index_db.search(store.kb_dir, q, limit=limit)
hits = index_db.search(store.kb_dir, q, limit=fetch_limit)
used = "fts5" if hits else used
if not hits and backend in ("auto", "substring"):
hits = store.search_substring(q, limit=limit)
hits = store.search_substring(q, limit=fetch_limit)
used = "substring"
if backend == "hybrid":
emb = index_db.search_semantic(store.kb_dir, q, limit=limit * 2)
fts = index_db.search(store.kb_dir, q, limit=limit * 2)
hits = rrf_fuse(emb, fts, limit=limit)
emb = index_db.search_semantic(store.kb_dir, q, limit=fetch_limit * 2)
fts = index_db.search(store.kb_dir, q, limit=fetch_limit * 2)
hits = rrf_fuse(emb, fts, limit=fetch_limit)
used = "hybrid"

hits = filter_hits(store, hits, viewer, limit=limit)

if rerank and hits:
try:
from .embeddings.rerank import default_reranker
Expand All @@ -1275,6 +1290,18 @@ def search(
except ImportError:
click.echo("warning: rerank extras not installed; skipping rerank", err=True)

if as_json:
_emit_json({
"backend": used,
"viewer": {"project": viewer.project, "agent": viewer.agent},
"hits": [
{"kind": k, "id": i, "snippet": snip, "score": score,
"backend": used}
for k, i, snip, score in hits
],
})
return

for k, i, snip, score in hits:
if explain:
click.echo(f"[{used}] {k}/{i}\tscore={score:.4f}\t{snip} ({used})")
Expand All @@ -1288,8 +1315,16 @@ def search(
@click.option("--max-chars", default=None, type=int)
@click.option("--require-citations", is_flag=True)
@click.option("--min-items", default=0, type=int)
@click.option("--project", default=None, help="Viewer project for scope filtering.")
@click.option("--agent", default=None, help="Viewer agent for scope filtering.")
def context(
task: str, limit: int, max_chars: int | None, require_citations: bool, min_items: int
task: str,
limit: int,
max_chars: int | None,
require_citations: bool,
min_items: int,
project: str | None,
agent: str | None,
) -> None:
"""Build a ContextPack ready to inject into an agent prompt."""
store = _load_store()
Expand All @@ -1300,6 +1335,8 @@ def context(
max_chars=max_chars,
min_items=min_items,
require_citations=require_citations,
project=project,
agent=agent,
)
_emit_json(pack)

Expand Down
42 changes: 34 additions & 8 deletions src/vouch/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@

from . import index_db
from .models import ClaimStatus, ContextItem, ContextPack, ContextQuality
from .scoping import (
ViewerContext,
filter_hits,
scoped_fetch_limit,
viewer_from,
)
from .storage import ArtifactNotFoundError, KBStore

# Claim statuses that have been explicitly retracted from active circulation.
Expand Down Expand Up @@ -67,8 +73,12 @@ def _configured_backend(store: KBStore) -> str:
return "auto"


def _retrieve(store: KBStore, query: str, limit: int
) -> list[tuple[str, str, str, float, str]]:
def _retrieve(
store: KBStore,
query: str,
limit: int,
viewer: ViewerContext,
) -> list[tuple[str, str, str, float, str]]:
"""Return list of (kind, id, summary, score, backend).

The backend is chosen by `retrieval.backend` in config.yaml:
Expand All @@ -78,19 +88,22 @@ def _retrieve(store: KBStore, query: str, limit: int
- "substring": substring scan only
"""
backend = _configured_backend(store)
fetch_limit = scoped_fetch_limit(limit, viewer)

if backend in ("auto", "embedding"):
raw = index_db.search_semantic(store.kb_dir, query, limit=limit)
raw = index_db.search_semantic(store.kb_dir, query, limit=fetch_limit)
if raw:
return [(k, i, s, sc, "embedding") for k, i, s, sc in raw]
filtered = filter_hits(store, raw, viewer, limit=limit)
return [(k, i, s, sc, "embedding") for k, i, s, sc in filtered]
if backend == "embedding":
return []

if backend in ("auto", "fts5"):
try:
hits = index_db.search(store.kb_dir, query, limit=limit)
hits = index_db.search(store.kb_dir, query, limit=fetch_limit)
if hits:
return [(k, i, s, sc, "fts5") for k, i, s, sc in hits]
filtered = filter_hits(store, hits, viewer, limit=limit)
return [(k, i, s, sc, "fts5") for k, i, s, sc in filtered]
except sqlite3.Error:
# FTS5 unavailable, db missing, or schema mismatch — fall through
# to substring scan (auto) or empty (explicit fts5). Other
Expand All @@ -99,9 +112,11 @@ def _retrieve(store: KBStore, query: str, limit: int
if backend == "fts5":
return []

substring_hits = store.search_substring(query, limit=fetch_limit)
filtered = filter_hits(store, substring_hits, viewer, limit=limit)
return [
(k, i, s, sc, "substring")
for k, i, s, sc in store.search_substring(query, limit=limit)
for k, i, s, sc in filtered
]


Expand Down Expand Up @@ -134,8 +149,15 @@ def build_context_pack(
fail_on_warnings: bool = False,
fail_on_budget_truncation: bool = False,
explain: bool = False,
project: str | None = None,
agent: str | None = None,
) -> ContextPack | dict[str, Any]:
hits = _retrieve(store, query, limit)
viewer = viewer_from(
config_path=store.config_path,
project=project,
agent=agent,
)
hits = _retrieve(store, query, limit, viewer)
items: list[ContextItem] = []
for kind, hid, summary, score, backend in hits:
cites: list[str] = []
Expand Down Expand Up @@ -214,6 +236,10 @@ def build_context_pack(

pack = ContextPack(query=query, items=items, quality=quality, warnings=warnings)
result: dict[str, Any] = pack.model_dump()
result["viewer"] = {
"project": viewer.project,
"agent": viewer.agent,
}
# Determine the backend used (all hits share the same backend in _retrieve).
result["backend"] = hits[0][4] if hits else "none"
if explain:
Expand Down
Loading
Loading