diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b0113b4..309d8fd7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 — diff --git a/src/vouch/capabilities.py b/src/vouch/capabilities.py index e7720687..2ec92cf2 100644 --- a/src/vouch/capabilities.py +++ b/src/vouch/capabilities.py @@ -37,6 +37,7 @@ "kb.search", "kb.explain_ranking", "kb.neighbors", + "kb.backlinks", "kb.experts", "kb.context", "kb.synthesize", diff --git a/src/vouch/cli.py b/src/vouch/cli.py index 1f1aebc8..6960999c 100644 --- a/src/vouch/cli.py +++ b/src/vouch/cli.py @@ -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.") diff --git a/src/vouch/hot_memory.py b/src/vouch/hot_memory.py index e07a66ac..750fc9bc 100644 --- a/src/vouch/hot_memory.py +++ b/src/vouch/hot_memory.py @@ -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": ( diff --git a/src/vouch/jsonl_server.py b/src/vouch/jsonl_server.py index bdd4fd9f..aa264574 100644 --- a/src/vouch/jsonl_server.py +++ b/src/vouch/jsonl_server.py @@ -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, @@ -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"] @@ -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, diff --git a/src/vouch/server.py b/src/vouch/server.py index c2b3221a..3426d0ef 100644 --- a/src/vouch/server.py +++ b/src/vouch/server.py @@ -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, @@ -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, diff --git a/src/vouch/wiki_render.py b/src/vouch/wiki_render.py index 313a8755..5f7cd727 100644 --- a/src/vouch/wiki_render.py +++ b/src/vouch/wiki_render.py @@ -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: diff --git a/tests/test_wiki_render.py b/tests/test_wiki_render.py index 38914706..c1c8302e 100644 --- a/tests/test_wiki_render.py +++ b/tests/test_wiki_render.py @@ -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( @@ -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