Skip to content
Open
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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,19 @@ All notable changes to vouch are documented here. Format follows
## [Unreleased]

### Added
- **`kb.backlinks` — the wiki's link graph, agent-facing** (roadmap 1.4):
`wiki_render.backlinks()` already computed the inbound-link map internally
(used by `render_moc`'s ranking), but nothing exposed it — no MCP tool, no
JSONL handler, no CLI command, only reachable indirectly via
`vouch render-wiki`'s rendered markdown. `kb.backlinks` (MCP `kb_backlinks`,
JSONL `kb.backlinks`, CLI `vouch backlinks [page_id]`) returns it directly:
with a `page_id`, that page's inbound *and* outbound `[[wikilink]]` titles
(`outbound_links`, a new sibling to `backlinks` in `wiki_render.py`); with
none, the full inbound map. Archived pages are excluded from the checked
set and treated as unresolvable link targets, matching `render-wiki`'s own
exclusion policy (#695) — a link to an archived page is exactly as dead as
a link to nothing. Read-only, like every other `wiki_render` view — never
proposes, writes, or mutates.
- **bench: composite guards** (#616): `efficiency`, `consistency` and `canary`
as bounded multipliers over the composite, plus a `bench_version` stamp on
every report. Reported **beside** the composite, never folded into it —
Expand Down
1 change: 1 addition & 0 deletions src/vouch/capabilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"kb.search",
"kb.explain_ranking",
"kb.neighbors",
"kb.backlinks",
"kb.experts",
"kb.context",
"kb.synthesize",
Expand Down
20 changes: 20 additions & 0 deletions src/vouch/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -3495,6 +3495,26 @@ def render_wiki_cmd(out_dir: str | None) -> None:
_echo(f"rendered {len(pages)} page(s) → {target}/index.md + MOC.md")


@cli.command()
@click.argument("page_id", required=False, default=None)
def backlinks(page_id: str | None) -> None:
"""Inbound + outbound [[wikilink]] edges over the approved wiki.

With PAGE_ID, show that page's inbound/outbound links. Without, print
the full inbound map. Archived pages are excluded, same as render-wiki.
"""
store = _load_store()
pages = [p for p in store.list_pages() if p.status is not PageStatus.ARCHIVED]
if page_id is None:
_emit_json({"backlinks": wiki_render_mod.backlinks(pages)})
return
with _cli_errors():
result = wiki_render_mod.page_links(pages, page_id)
if result is None:
raise ValueError(f"page {page_id} not found")
_emit_json({"page_id": page_id, **result})


@cli.command()
@click.argument("session_id")
@click.option("--no-page", is_flag=True, help="Skip the session-summary page.")
Expand Down
1 change: 1 addition & 0 deletions src/vouch/hot_memory.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ def mark_volunteered(session_id: str, claim_id: str, *, pushed_at: float) -> Non
"kb.digest": "aggregated reviewer briefing — sidebar would duplicate its own recency content",
"kb.activity": "aggregated audit-log buckets — sidebar would duplicate counts",
"kb.neighbors": "graph slice — out of scope for recency sidebar",
"kb.backlinks": "wiki link-graph slice — out of scope for recency sidebar",
"kb.synthesize": "answer-mode prose — sidebar adds noise",
"kb.diff": "field-level revision diff — self-contained, not a claim browse",
"kb.explain_ranking": (
Expand Down
15 changes: 14 additions & 1 deletion src/vouch/jsonl_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,11 @@
from . import skills as skills_mod
from . import trust as trust_mod
from . import verify as verify_mod
from . import wiki_render as wiki_render_mod
from .capabilities import capabilities as build_caps
from .context import build_context_pack
from .logging_config import configure_logging
from .models import ProposalStatus
from .models import PageStatus, ProposalStatus
from .page_filters import filter_pages
from .proposals import (
EXPIRE_ACTOR,
Expand Down Expand Up @@ -230,6 +231,17 @@ def _h_neighbors(p: dict) -> dict:
)


def _h_backlinks(p: dict) -> dict:
pages = [pg for pg in _store().list_pages() if pg.status is not PageStatus.ARCHIVED]
page_id = p.get("page_id")
if page_id is None:
return {"backlinks": wiki_render_mod.backlinks(pages)}
result = wiki_render_mod.page_links(pages, page_id)
if result is None:
raise ValueError(f"page {page_id} not found")
return {"page_id": page_id, **result}


def _h_context(p: dict) -> dict:
store = _store()
query = p["task"]
Expand Down Expand Up @@ -997,6 +1009,7 @@ def _h_propose_theme(p: dict) -> dict:
"kb.search": _h_search,
"kb.explain_ranking": _h_explain_ranking,
"kb.neighbors": _h_neighbors,
"kb.backlinks": _h_backlinks,
"kb.experts": _h_experts,
"kb.context": _h_context,
"kb.synthesize": _h_synthesize,
Expand Down
22 changes: 21 additions & 1 deletion src/vouch/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,12 @@
from . import skills as skills_mod
from . import trust as trust_mod
from . import verify as verify_mod
from . import wiki_render as wiki_render_mod
from .capabilities import capabilities as build_caps
from .context import build_context_pack
from .lifecycle import LifecycleError
from .logging_config import configure_logging
from .models import ProposalStatus
from .models import PageStatus, ProposalStatus
from .page_filters import filter_pages
from .proposals import (
EXPIRE_ACTOR,
Expand Down Expand Up @@ -327,6 +328,25 @@ def kb_neighbors(
raise ValueError(str(e)) from e


@mcp.tool()
def kb_backlinks(page_id: str | None = None) -> dict[str, Any]:
"""Inbound + outbound [[wikilink]] edges over the approved wiki.

With ``page_id``, returns that page's inbound/outbound link titles.
Without, returns the full inbound map for every live page (the same
graph `wiki_render.render_moc` ranks pages by, exposed as raw data).
Archived pages are excluded from the checked set and treated as
unresolvable link targets, matching `render-wiki`'s own policy (#695).
"""
pages = [p for p in _store().list_pages() if p.status is not PageStatus.ARCHIVED]
if page_id is None:
return {"backlinks": wiki_render_mod.backlinks(pages)}
result = wiki_render_mod.page_links(pages, page_id)
if result is None:
raise ValueError(f"page {page_id} not found")
return {"page_id": page_id, **result}


@mcp.tool()
def kb_context(
task: str,
Expand Down
35 changes: 35 additions & 0 deletions src/vouch/wiki_render.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,41 @@ def backlinks(pages: list[Page]) -> dict[str, list[str]]:
return {pid: sorted(titles) for pid, titles in inbound.items()}


def outbound_links(page: Page, pages: list[Page]) -> list[str]:
"""Titles of pages ``page``'s body links to, resolved and deduplicated.

Self-links are dropped, matching ``backlinks()``'s own exclusion. Order
is first-occurrence in the body text, not sorted - ``backlinks()`` sorts
because it aggregates across many source pages, but outbound is already
one page's own authored order.
"""
index = _link_index(pages)
seen: set[str] = set()
out: list[str] = []
for raw in _WIKILINK_RE.findall(page.body):
target = index.get(raw.strip().lower())
if target is not None and target.id != page.id and target.id not in seen:
seen.add(target.id)
out.append(target.title)
return out


def page_links(pages: list[Page], page_id: str) -> dict[str, list[str]] | None:
"""Inbound + outbound wikilink titles for one page.

``None`` if ``page_id`` doesn't match any page in ``pages`` - the caller
decides how to report that (``kb.backlinks`` raises, matching
``kb.neighbors``' contract for an unknown root node).
"""
page = next((p for p in pages if p.id == page_id), None)
if page is None:
return None
return {
"inbound": backlinks(pages).get(page_id, []),
"outbound": outbound_links(page, pages),
}


def render_index(pages: list[Page]) -> str:
"""Render an index grouped by page type, each entry with its summary."""
if not pages:
Expand Down
163 changes: 162 additions & 1 deletion tests/test_wiki_render.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,17 @@

from __future__ import annotations

import json
from pathlib import Path

import pytest
from click.testing import CliRunner

from vouch import wiki_render
from vouch.models import Page
from vouch.cli import cli
from vouch.jsonl_server import handle_request
from vouch.models import Page, PageStatus
from vouch.storage import KBStore


def _page(
Expand Down Expand Up @@ -104,3 +113,155 @@ def test_render_moc_ranks_by_inbound_links() -> None:
# Gamma has 2 inbound links; it must rank above the 0-inbound pages.
assert out.index("Gamma") < out.index("Alpha")
assert out.index("Gamma") < out.index("Beta")


# --- outbound_links / page_links (kb.backlinks) ---------------------------


def test_outbound_links_resolves_and_excludes_self() -> None:
a = _page("Alpha", body="see [[Beta]] and also [[Alpha]] (self)", pid="alpha")
b = _page("Beta", pid="beta")
assert wiki_render.outbound_links(a, [a, b]) == ["Beta"]


def test_outbound_links_deduplicates_repeated_links() -> None:
a = _page("Alpha", body="see [[Beta]] and again [[Beta]]", pid="alpha")
b = _page("Beta", pid="beta")
assert wiki_render.outbound_links(a, [a, b]) == ["Beta"]


def test_outbound_links_drops_unresolved() -> None:
a = _page("Alpha", body="see [[Ghost]]", pid="alpha")
assert wiki_render.outbound_links(a, [a]) == []


def test_page_links_combines_inbound_and_outbound() -> None:
a = _page("Alpha", body="see [[Beta]]", pid="alpha")
b = _page("Beta", body="see [[Gamma]]", pid="beta")
g = _page("Gamma", pid="gamma")
pages = [a, b, g]
assert wiki_render.page_links(pages, "beta") == {
"inbound": ["Alpha"],
"outbound": ["Gamma"],
}


def test_page_links_returns_none_for_unknown_page() -> None:
a = _page("Alpha", pid="alpha")
assert wiki_render.page_links([a], "nope") is None


# --- kb.backlinks (server/jsonl/cli registration) --------------------------


@pytest.fixture
def store(tmp_path: Path) -> KBStore:
return KBStore.init(tmp_path)


def _put(
store: KBStore, pid: str, title: str, body: str,
*, status: PageStatus = PageStatus.ACTIVE,
) -> None:
store.put_page(Page(id=pid, title=title, body=body, status=status))


def test_jsonl_backlinks_single_page(store: KBStore, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.chdir(store.root)
_put(store, "alpha", "Alpha", "see [[Beta]] for more.")
_put(store, "beta", "Beta", "a leaf page.")
resp = handle_request(
{"id": "b1", "method": "kb.backlinks", "params": {"page_id": "beta"}}
)
assert resp["ok"] is True
assert resp["result"]["inbound"] == ["Alpha"]
assert resp["result"]["outbound"] == []


def test_jsonl_backlinks_full_map_with_no_page_id(
store: KBStore, monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.chdir(store.root)
_put(store, "alpha", "Alpha", "see [[Beta]] for more.")
_put(store, "beta", "Beta", "a leaf page.")
resp = handle_request({"id": "b2", "method": "kb.backlinks", "params": {}})
assert resp["ok"] is True
assert resp["result"]["backlinks"] == {"beta": ["Alpha"]}


def test_jsonl_backlinks_unknown_page_errors(
store: KBStore, monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.chdir(store.root)
resp = handle_request(
{"id": "b3", "method": "kb.backlinks", "params": {"page_id": "nope"}}
)
assert resp["ok"] is False
assert resp["error"]["code"] == "invalid_request"


def test_jsonl_backlinks_excludes_archived_pages(
store: KBStore, monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Archived pages are out of the wiki front door (#695) — a link to one
is exactly as dead as a link to nothing, so it's dropped from both the
inbound map and treated as unresolved for outbound purposes."""
monkeypatch.chdir(store.root)
_put(store, "gone", "Gone", "retired content.", status=PageStatus.ARCHIVED)
_put(store, "linker", "Linker", "see [[Gone]] for the old details.")
resp = handle_request(
{"id": "b4", "method": "kb.backlinks", "params": {"page_id": "linker"}}
)
assert resp["ok"] is True
assert resp["result"]["outbound"] == []
full = handle_request({"id": "b5", "method": "kb.backlinks", "params": {}})
assert "gone" not in full["result"]["backlinks"]


def test_mcp_surface_serves_backlinks(store: KBStore, monkeypatch: pytest.MonkeyPatch) -> None:
from vouch import server

_put(store, "alpha", "Alpha", "see [[Beta]] for more.")
_put(store, "beta", "Beta", "a leaf page.")
monkeypatch.setattr(server, "_store", lambda: store)

single = server.kb_backlinks("beta")
assert single["inbound"] == ["Alpha"]
assert single["outbound"] == []

full = server.kb_backlinks()
assert full["backlinks"] == {"beta": ["Alpha"]}

with pytest.raises(ValueError, match="not found"):
server.kb_backlinks("nope")


def test_cli_backlinks_full_map(store: KBStore, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.chdir(store.root)
_put(store, "alpha", "Alpha", "see [[Beta]] for more.")
_put(store, "beta", "Beta", "a leaf page.")
runner = CliRunner()
result = runner.invoke(cli, ["backlinks"])
assert result.exit_code == 0, result.output
payload = json.loads(result.output)
assert payload["backlinks"] == {"beta": ["Alpha"]}


def test_cli_backlinks_single_page(store: KBStore, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.chdir(store.root)
_put(store, "alpha", "Alpha", "see [[Beta]] for more.")
_put(store, "beta", "Beta", "a leaf page.")
runner = CliRunner()
result = runner.invoke(cli, ["backlinks", "beta"])
assert result.exit_code == 0
assert '"inbound"' in result.output
assert "Alpha" in result.output


def test_cli_backlinks_unknown_page_errors(
store: KBStore, monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.chdir(store.root)
runner = CliRunner()
result = runner.invoke(cli, ["backlinks", "nope"])
assert result.exit_code != 0
Loading