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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ All notable changes to vouch are documented here. Format follows
- `vouch serve` now fails fast with a clear `vouch init` hint when no `.vouch/` KB is present, instead of starting a server that immediately misbehaves (#95).

### Added
- Auto-extracted typed edges: approving a page now files `mentions` (wiki-links),
`relates_to` (entity frontmatter), and `derived_from` (source frontmatter)
relation proposals automatically, tagged `proposed_by: vouch-extractor`.
They land in `proposed/` like any hand-filed relation and need the usual
review; `vouch reject-extracted [--page <id>]` mass-rejects them (#224).
### Added
- `vouch sync --vault <dir>` — bidirectional sync between the KB and an
Obsidian/Logseq-style markdown vault. Forward (vault → KB): edits to
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.propose_relation",
"kb.approve",
"kb.reject",
"kb.reject_extracted",
"kb.expire",
"kb.supersede",
"kb.contradict",
Expand Down
17 changes: 17 additions & 0 deletions src/vouch/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
propose_entity,
propose_page,
propose_relation,
reject_auto_extracted,
)
from .proposals import (
approve as do_approve,
Expand Down Expand Up @@ -843,6 +844,22 @@ def reject(proposal_id: str, reason: str) -> None:
click.echo(f"Rejected {proposal_id}")


@cli.command("reject-extracted")
@click.option("--page", "page_id", default=None, help="Limit to edges extracted from one page id.")
@click.option("--reason", default="auto-extracted edge rejected in bulk")
def reject_extracted(page_id: str | None, reason: str) -> None:
"""Mass-reject pending edges the auto-extractor filed (issue #224)."""
store = _load_store()
with _cli_errors():
rejected = reject_auto_extracted(
store, rejected_by=_whoami(), page_id=page_id, reason=reason,
)
if not rejected:
click.echo("no pending auto-extracted edges to reject")
return
click.echo(f"Rejected {len(rejected)} auto-extracted edge proposal(s)")


def _expire_row(proposal: Proposal) -> dict[str, Any]:
return {
"id": proposal.id,
Expand Down
7 changes: 7 additions & 0 deletions src/vouch/extractors/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
"""Post-approval extraction passes that file proposals, never artifacts.

Extractors run after `proposals.approve` lands a durable artifact and only
ever call back into `proposals.propose_*`. They never call `store.put_*`
directly -- the review gate applies to extracted edges exactly like it
applies to anything an agent proposes by hand.
"""
77 changes: 77 additions & 0 deletions src/vouch/extractors/edges.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
"""Auto-extracted typed edges for approved pages (issue #224).

Parses an approved page for `[[entity-id]]` wiki-links and reuses the
`entities` / `sources` frontmatter lists `propose_page` already validated,
then files each implied relationship as an ordinary relation proposal.
`proposed_by=AUTO_EXTRACTOR_ACTOR` is the only thing that distinguishes an
extracted edge from a hand-filed one -- it still lands in `proposed/` and
needs a reviewer's approve/reject like any other write.
"""

from __future__ import annotations

import re

from ..models import Page, Proposal, RelationType
from ..proposals import ProposalError, propose_relation
from ..storage import KBStore

AUTO_EXTRACTOR_ACTOR = "vouch-extractor"

_WIKILINK_RE = re.compile(r"\[\[([A-Za-z0-9][A-Za-z0-9._-]*)\]\]")


def extract_wikilinks(body: str) -> list[str]:
"""Distinct `[[entity-id]]` targets in `body`, in first-seen order."""
seen: dict[str, None] = {}
for match in _WIKILINK_RE.finditer(body):
seen.setdefault(match.group(1), None)
return list(seen)


def auto_propose_edges(
store: KBStore,
page: Page,
*,
proposed_by: str = AUTO_EXTRACTOR_ACTOR,
session_id: str | None = None,
) -> list[Proposal]:
"""File relation proposals implied by an approved page's links.

Three edge kinds, one per existing page field:
- `mentions` -- `[[wiki-links]]` found in the page body
- `relates_to` -- the page's `entities` frontmatter list
- `derived_from` -- the page's `sources` frontmatter list

Edges are proposed independently of each other; a proposal that fails
(e.g. an empty endpoint) is skipped rather than blocking the rest or
the page approval that triggered extraction.
"""
edges: list[tuple[RelationType, str]] = []
for target in extract_wikilinks(page.body):
if target != page.id:
edges.append((RelationType.MENTIONS, target))
for entity_id in page.entities:
if entity_id != page.id:
edges.append((RelationType.RELATES_TO, entity_id))
for source_id in page.sources:
edges.append((RelationType.DERIVED_FROM, source_id))

proposals: list[Proposal] = []
for relation, target in edges:
try:
proposals.append(
propose_relation(
store,
src=page.id,
relation=relation.value,
target=target,
proposed_by=proposed_by,
confidence=0.5,
rationale=f"auto-extracted from page {page.id}",
session_id=session_id,
)
)
except ProposalError:
continue
return proposals
10 changes: 10 additions & 0 deletions src/vouch/jsonl_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
propose_page,
propose_relation,
reject,
reject_auto_extracted,
)
from .stats import collect_stats
from .storage import (
Expand Down Expand Up @@ -336,6 +337,14 @@ def _h_reject(p: dict) -> dict:
return {"proposal_id": p["proposal_id"], "status": "rejected"}


def _h_reject_extracted(p: dict) -> dict:
kwargs: dict[str, Any] = {"page_id": p.get("page_id")}
if p.get("reason"):
kwargs["reason"] = p["reason"]
rejected = reject_auto_extracted(_store(), rejected_by=_agent(), **kwargs)
return {"rejected": [pr.id for pr in rejected]}


def _h_expire(p: dict) -> dict:
result = expire_pending(
_store(),
Expand Down Expand Up @@ -586,6 +595,7 @@ def _h_provenance_rebuild(_: dict) -> dict:
"kb.propose_relation": _h_propose_relation,
"kb.approve": _h_approve,
"kb.reject": _h_reject,
"kb.reject_extracted": _h_reject_extracted,
"kb.expire": _h_expire,
"kb.supersede": _h_supersede,
"kb.contradict": _h_contradict,
Expand Down
2 changes: 2 additions & 0 deletions src/vouch/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,8 @@ class RelationType(StrEnum):
BLOCKS = "blocks"
IMPLEMENTS = "implements"
REFERENCES = "references"
MENTIONS = "mentions"
RELATES_TO = "relates_to"


class PageType(StrEnum):
Expand Down
30 changes: 30 additions & 0 deletions src/vouch/proposals.py
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,11 @@ def approve(
type=page.type.value, tags=page.tags,
)
result = page
# Lazy import: extractors.edges calls back into propose_relation,
# so importing it at module scope would be circular.
from .extractors.edges import auto_propose_edges

auto_propose_edges(store, page, session_id=proposal.session_id)
elif proposal.kind == ProposalKind.ENTITY:
entity = Entity(**payload)
store.put_entity(entity)
Expand Down Expand Up @@ -443,6 +448,31 @@ def reject(
return proposal


def reject_auto_extracted(
store: KBStore,
*,
rejected_by: str,
page_id: str | None = None,
reason: str = "auto-extracted edge rejected in bulk",
) -> list[Proposal]:
"""Mass-reject pending edges filed by the auto-extractor.

Scoped to `AUTO_EXTRACTOR_ACTOR` proposals so this never touches a
hand-filed relation. `page_id` narrows to edges extracted from one
originating page (the relation payload's `source`).
"""
from .extractors.edges import AUTO_EXTRACTOR_ACTOR

targets = [
p
for p in store.list_proposals(ProposalStatus.PENDING)
if p.kind == ProposalKind.RELATION
and p.proposed_by == AUTO_EXTRACTOR_ACTOR
and (page_id is None or p.payload.get("source") == page_id)
]
return [reject(store, p.id, rejected_by=rejected_by, reason=reason) for p in targets]


def expire_pending_after_days(store: KBStore, *, override: int | None = None) -> int:
"""Resolve GC threshold from config (`review.expire_pending_after_days`)."""
if override is not None:
Expand Down
20 changes: 20 additions & 0 deletions src/vouch/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
propose_page,
propose_relation,
reject,
reject_auto_extracted,
)
from .scoping import filter_hits, scoped_fetch_limit, viewer_from
from .stats import collect_stats
Expand Down Expand Up @@ -455,6 +456,25 @@ def kb_reject(proposal_id: str, reason: str) -> dict[str, Any]:
return {"proposal_id": proposal_id, "status": "rejected", "reason": reason}


@mcp.tool()
def kb_reject_extracted(
page_id: str | None = None, reason: str | None = None,
) -> dict[str, Any]:
"""Mass-reject pending edges the auto-extractor filed (issue #224).

Scope to one page's edges with `page_id`, or omit it to clear every
pending auto-extracted edge across the KB.
"""
try:
rejected = reject_auto_extracted(
_store(), rejected_by=_agent(), page_id=page_id,
**({"reason": reason} if reason else {}),
)
except (ArtifactNotFoundError, ValueError, ProposalError) as e:
raise ValueError(str(e)) from e
return {"rejected": [p.id for p in rejected]}


@mcp.tool()
def kb_expire(apply: bool = False, days: int | None = None) -> dict[str, Any]:
"""Expire stale pending proposals (dry-run unless apply=True)."""
Expand Down
102 changes: 102 additions & 0 deletions tests/test_extractors_edges.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
"""Auto-extracted typed edges on approved pages (issue #224)."""

from __future__ import annotations

from pathlib import Path

import pytest

from vouch.extractors.edges import AUTO_EXTRACTOR_ACTOR, extract_wikilinks
from vouch.models import (
Entity,
EntityType,
ProposalKind,
ProposalStatus,
RelationType,
)
from vouch.proposals import approve, propose_page, reject_auto_extracted
from vouch.storage import KBStore


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


def test_extract_wikilinks_dedupes_and_preserves_order() -> None:
body = "see [[alice]] and [[bob]], also [[alice]] again"
assert extract_wikilinks(body) == ["alice", "bob"]


def test_approving_page_with_wikilinks_files_mentions_proposals(
store: KBStore,
) -> None:
store.put_entity(Entity(id="alice", name="Alice", type=EntityType.PERSON))
store.put_entity(Entity(id="bob", name="Bob", type=EntityType.PERSON))
store.put_entity(Entity(id="carol", name="Carol", type=EntityType.PERSON))
pr = propose_page(
store, title="Team",
body="works with [[alice]], [[bob]], and [[carol]]",
proposed_by="agent-a",
)
page = approve(store, pr.id, approved_by="reviewer")

pending = store.list_proposals(ProposalStatus.PENDING)
edges = [p for p in pending if p.kind == ProposalKind.RELATION]
assert len(edges) == 3
assert {e.payload["target"] for e in edges} == {"alice", "bob", "carol"}
assert all(e.payload["source"] == page.id for e in edges)
assert all(e.payload["relation"] == RelationType.MENTIONS.value for e in edges)
assert all(e.proposed_by == AUTO_EXTRACTOR_ACTOR for e in edges)


def test_approving_page_with_entities_and_sources_files_typed_edges(
store: KBStore,
) -> None:
store.put_entity(Entity(id="acme", name="Acme", type=EntityType.COMPANY))
src = store.put_source(b"doc")
pr = propose_page(
store, title="Acme overview", body="no links here",
entity_ids=["acme"], source_ids=[src.id], proposed_by="agent-a",
)
approve(store, pr.id, approved_by="reviewer")

pending = store.list_proposals(ProposalStatus.PENDING)
edges = {p.payload["relation"]: p for p in pending if p.kind == ProposalKind.RELATION}
assert edges[RelationType.RELATES_TO.value].payload["target"] == "acme"
assert edges[RelationType.DERIVED_FROM.value].payload["target"] == src.id


def test_dangling_wikilink_is_skipped_not_an_error(store: KBStore) -> None:
pr = propose_page(
store, title="Orphan", body="mentions [[ghost-entity]] only",
proposed_by="agent-a",
)
# Approval must not raise even though the wiki-link target doesn't exist.
approve(store, pr.id, approved_by="reviewer")
pending = store.list_proposals(ProposalStatus.PENDING)
assert [p for p in pending if p.kind == ProposalKind.RELATION] == []


def test_approving_page_without_links_files_no_edges(store: KBStore) -> None:
pr = propose_page(store, title="Plain", body="no links", proposed_by="agent-a")
approve(store, pr.id, approved_by="reviewer")
pending = store.list_proposals(ProposalStatus.PENDING)
assert [p for p in pending if p.kind == ProposalKind.RELATION] == []


def test_reject_auto_extracted_bulk_rejects_only_extractor_proposals(
store: KBStore,
) -> None:
store.put_entity(Entity(id="alice", name="Alice", type=EntityType.PERSON))
pr = propose_page(
store, title="Team", body="works with [[alice]]", proposed_by="agent-a",
)
page = approve(store, pr.id, approved_by="reviewer")

rejected = reject_auto_extracted(store, rejected_by="reviewer", page_id=page.id)
assert len(rejected) == 1
assert rejected[0].status == ProposalStatus.REJECTED

pending = store.list_proposals(ProposalStatus.PENDING)
assert [p for p in pending if p.kind == ProposalKind.RELATION] == []