diff --git a/CHANGELOG.md b/CHANGELOG.md index 986dcb0e..47f9cc75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,17 @@ All notable changes to vouch are documented here. Format follows ## [Unreleased] +### Added +- `kb.enrich_page` / `vouch enrich-page` / `vouch enrich-pages` — thin-page + enrichment: scores a page against `enrichment.min_body_chars` / + `enrichment.min_citations` in `.vouch/config.yaml`, and drafts an addition + synthesized strictly from approved, non-retracted claims already in the KB + (reusing `kb.synthesize`'s machinery) appended to the page's existing body. + Never writes durably — the enriched revision is filed as a pending page + proposal (`slug_hint` targets the existing page id) for a human to clear + with `vouch review`. `--dry-run`, `--min-body-chars`, `--min-citations`, + `--limit` (batch only), `--json`. see `docs/enrich.md`. + ## [1.2.2] — 2026-07-07 ### Packaging diff --git a/docs/enrich.md b/docs/enrich.md new file mode 100644 index 00000000..1b5d8d50 --- /dev/null +++ b/docs/enrich.md @@ -0,0 +1,61 @@ +# vouch enrich-page / enrich-pages — thin-page enrichment + +A page can land as a stub: a title, a one-line body, and few or no +`claims`/`sources` citations. Those are the pages least useful to a +retrieving agent, and the ones most likely to stay thin because nobody +circles back to fill them in — even though the KB often already holds +approved claims that speak directly to the stub's topic. + +`vouch enrich-page` / `vouch enrich-pages` find those thin pages and draft +an enriched revision synthesized *strictly* from related, approved, non- +retracted claims already in the KB (reusing the same machinery as +`kb.synthesize` — see `docs/`) — no external fetch, no invented sentences. +Like `vouch compile`, this only ever proposes: the enriched page is filed +as a pending page proposal a human clears with `vouch review`. + +## What counts as thin + +A page qualifies when either threshold is under its configured minimum: + +```yaml +enrichment: + min_body_chars: 200 # page.body shorter than this... + min_citations: 2 # ...or len(page.claims) + len(page.sources) below this +``` + +Both are overridable per-invocation with `--min-body-chars` / `--min-citations`. + +## Run it + +```bash +vouch enrich-page # score + draft one page +vouch enrich-page --dry-run # show what would be proposed, file nothing + +vouch enrich-pages # scan all pages, file one proposal per thin page +vouch enrich-pages --dry-run # list qualifying pages + candidate claim ids +vouch enrich-pages --limit 10 # cap how many thin pages are processed this run +vouch review # the human half: approve / reject each draft +``` + +## How the addition is built + +For a qualifying page, the page's title + body become the query for +`kb.synthesize`, which answers strictly from approved, non-retracted claims +with one cited clause per claim. The result is appended to the page's +existing body (the stub's hand-written text is never discarded or +overwritten) and its cited claim ids are unioned with the page's existing +`claims`. Evidence ids of those claims that resolve to registered sources +are unioned into the page's `sources` list the same way. + +A page is skipped (no proposal filed) when: + +- it's already above both thresholds, +- no approved claim matches its topic (nothing to cite), or +- the proposal is rejected at the gate (e.g. a page kind's required fields + tightened since the page was last approved) — this drops only that one + page rather than sinking a batch `enrich-pages` run. + +Proposals are filed by the `page-enricher` actor (or `VOUCH_AGENT` when +set), so under the default gate the reviewing human is always a different +actor and self-approval stays impossible. Each non-dry `enrich-pages` run +appends an `enrich_pages.run` audit event with the filed proposal ids. diff --git a/src/vouch/capabilities.py b/src/vouch/capabilities.py index e051115d..bd69a0d1 100644 --- a/src/vouch/capabilities.py +++ b/src/vouch/capabilities.py @@ -88,6 +88,7 @@ "kb.detect_themes", "kb.propose_theme", "kb.compile", + "kb.enrich_page", ] diff --git a/src/vouch/cli.py b/src/vouch/cli.py index 4f1078cc..e7a8c847 100644 --- a/src/vouch/cli.py +++ b/src/vouch/cli.py @@ -20,6 +20,7 @@ from . import __version__, bundle, health from . import audit as audit_mod +from . import enrich as enrich_mod from . import lifecycle as life from . import sessions as sess_mod from . import verify as verify_mod @@ -446,6 +447,87 @@ def session_end_cmd(session_id: str, note: str | None) -> None: _emit_json({"session": sess.id, "proposals": sess.proposal_ids}) +@cli.command(name="enrich-page") +@click.argument("page_id") +@click.option("--min-body-chars", type=int, default=None, + help="Override enrichment.min_body_chars for this run.") +@click.option("--min-citations", type=int, default=None, + help="Override enrichment.min_citations for this run.") +@click.option("--dry-run", is_flag=True, help="Score and draft; file nothing.") +@click.option("--json", "as_json", is_flag=True, help="Machine-readable report.") +def enrich_page_cmd( + page_id: str, min_body_chars: int | None, min_citations: int | None, + dry_run: bool, as_json: bool, +) -> None: + """Draft an enriched revision of one thin page (a page proposal). + + Scores PAGE_ID against the thin-page thresholds and, if it qualifies, + synthesizes an addition strictly from approved claims already in the KB + and files it as a pending page proposal. Approval stays a separate + human step (`vouch review`). + """ + store = _load_store() + actor = os.environ.get("VOUCH_AGENT") or enrich_mod.ENRICH_ACTOR + with _cli_errors(): + result = enrich_mod.enrich_page( + store, page_id, actor=actor, + min_body_chars=min_body_chars, min_citations=min_citations, + dry_run=dry_run, + ) + if as_json: + _emit_json(result.to_dict()) + return + if result.skipped_reason: + click.echo(f"skipped {page_id}: {result.skipped_reason}") + return + verb = "would propose" if dry_run else "proposed" + click.echo(f"{verb} enrichment of {page_id} citing {len(result.claim_ids)} claim(s)" + f"{'' if dry_run else f': {result.proposal_id}'}") + if not dry_run: + click.echo("run `vouch review` to decide.") + + +@cli.command(name="enrich-pages") +@click.option("--min-body-chars", type=int, default=None, + help="Override enrichment.min_body_chars for this run.") +@click.option("--min-citations", type=int, default=None, + help="Override enrichment.min_citations for this run.") +@click.option("--limit", type=int, default=None, + help="Cap how many thin pages are processed this run.") +@click.option("--dry-run", is_flag=True, + help="Report qualifying pages and candidate claims; file nothing.") +@click.option("--json", "as_json", is_flag=True, help="Machine-readable report.") +def enrich_pages_cmd( + min_body_chars: int | None, min_citations: int | None, limit: int | None, + dry_run: bool, as_json: bool, +) -> None: + """Scan pages for thin ones and file an enrichment proposal for each. + + `--dry-run` lists the qualifying pages and their candidate claim ids + without filing anything. + """ + store = _load_store() + actor = os.environ.get("VOUCH_AGENT") or enrich_mod.ENRICH_ACTOR + report = enrich_mod.enrich_pages( + store, actor=actor, triggered_by=_whoami(), + min_body_chars=min_body_chars, min_citations=min_citations, + limit=limit, dry_run=dry_run, + ) + if as_json: + _emit_json(report.to_dict()) + return + verb = "would propose" if dry_run else "proposed" + click.echo(f"{verb} enrichment for {len(report.proposed)} page(s):") + for row in report.proposed: + click.echo(f" • {row['page_id']} {row['proposal_id']}") + if report.skipped: + click.echo(f"skipped {len(report.skipped)}:") + for row in report.skipped: + click.echo(f" • {row['page_id']} — {row['reason']}") + if report.proposed and not dry_run: + click.echo("run `vouch review` to decide.") + + @cli.command() @click.argument("session_id") @click.option("--no-page", is_flag=True, help="Skip the session-summary page.") diff --git a/src/vouch/enrich.py b/src/vouch/enrich.py new file mode 100644 index 00000000..3c5c3a9a --- /dev/null +++ b/src/vouch/enrich.py @@ -0,0 +1,311 @@ +"""Thin-page enrichment — propose expansions for stub/sparse pages. + +A page can land as a stub: a title, a one-line body, and few or no +`claims`/`sources` citations. This module finds those thin pages and drafts +an enriched revision synthesized *strictly* from related approved material +already in the KB — no external fetch, no invented sentences. + +The heavy lifting is delegated to `synthesize.synthesize` (issue #222), +which already answers a query from approved, non-retracted claims with one +cited clause per claim. Reusing it here (rather than re-deriving clauses +from `context.build_context_pack` ourselves) is what keeps the "every +sentence traces to an approved claim" invariant identical between the two +features. + +Like `compile.compile_kb`, this only ever proposes: the enriched page is +filed via `proposals.propose_page` with `slug_hint` set to the existing page +id, and lands as a PENDING proposal. Nothing is written durably without a +human `kb.approve`. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +import yaml + +from . import audit as audit_mod +from .models import Page +from .proposals import ProposalError, propose_page +from .storage import ArtifactNotFoundError, KBStore +from .synthesize import synthesize + +DEFAULT_MIN_BODY_CHARS = 200 +DEFAULT_MIN_CITATIONS = 2 + +# The proposer identity for enrichment drafts. Deliberately not the human +# triggering the run: the default review gate refuses self-approval, so the +# reviewer who approves an enrichment proposal must be a different actor +# than the proposer (same reasoning as compile.py's COMPILE_ACTOR). +ENRICH_ACTOR = "page-enricher" + +# How wide a net synthesize() casts when gathering candidate claims, and how +# long the appended, cited addition to the body is allowed to grow. These +# are enrichment-pass internals, not part of the public surface (only the +# thin-page thresholds are configurable per the issue). +_SYNTHESIZE_DEPTH = 12 +_SYNTHESIZE_MAX_CHARS = 2000 + + +@dataclass(frozen=True) +class EnrichmentConfig: + min_body_chars: int = DEFAULT_MIN_BODY_CHARS + min_citations: int = DEFAULT_MIN_CITATIONS + + +def _coerce_int(value: Any, default: int) -> int: + # A config typo (min_body_chars: two-hundred) must degrade to the + # default, not take down every caller. + try: + n = int(value) + except (TypeError, ValueError): + return default + return n if n >= 0 else default + + +def load_config(store: KBStore) -> EnrichmentConfig: + """Read `enrichment:` from config.yaml; fall back to defaults.""" + try: + loaded = yaml.safe_load(store.config_path.read_text(encoding="utf-8")) + except (OSError, yaml.YAMLError): + return EnrichmentConfig() + if not isinstance(loaded, dict): + return EnrichmentConfig() + raw = loaded.get("enrichment") + if not isinstance(raw, dict): + return EnrichmentConfig() + return EnrichmentConfig( + min_body_chars=_coerce_int( + raw.get("min_body_chars", DEFAULT_MIN_BODY_CHARS), DEFAULT_MIN_BODY_CHARS, + ), + min_citations=_coerce_int( + raw.get("min_citations", DEFAULT_MIN_CITATIONS), DEFAULT_MIN_CITATIONS, + ), + ) + + +def _resolve_thresholds( + cfg: EnrichmentConfig, + *, + min_body_chars: int | None, + min_citations: int | None, +) -> EnrichmentConfig: + return EnrichmentConfig( + min_body_chars=cfg.min_body_chars if min_body_chars is None else min_body_chars, + min_citations=cfg.min_citations if min_citations is None else min_citations, + ) + + +def citation_count(page: Page) -> int: + return len(page.claims) + len(page.sources) + + +def is_thin(page: Page, cfg: EnrichmentConfig) -> bool: + """A page qualifies as thin under either threshold (issue #309).""" + return len(page.body) < cfg.min_body_chars or citation_count(page) < cfg.min_citations + + +@dataclass +class EnrichResult: + """Outcome of one `enrich_page` call: a filed proposal, or a skip reason.""" + + page_id: str + proposal_id: str | None = None + claim_ids: list[str] = field(default_factory=list) + source_ids: list[str] = field(default_factory=list) + skipped_reason: str | None = None + dry_run: bool = False + + def to_dict(self) -> dict[str, Any]: + return { + "page_id": self.page_id, + "proposed": self.skipped_reason is None, + "proposal_id": self.proposal_id, + "claim_ids": self.claim_ids, + "source_ids": self.source_ids, + "skipped_reason": self.skipped_reason, + "dry_run": self.dry_run, + } + + +def _sources_for_claims(store: KBStore, claim_ids: list[str]) -> list[str]: + """Evidence ids of the cited claims that resolve to registered Sources. + + `propose_page`'s `source_ids` are validated against `store.get_source` + only (a claim's `evidence` list may also hold bare Evidence ids — see + `models.Claim.evidence` — which aren't valid page sources), so this + filters down to the subset that would actually pass that check. + """ + seen: set[str] = set() + ordered: list[str] = [] + for cid in claim_ids: + try: + claim = store.get_claim(cid) + except ArtifactNotFoundError: + continue + for eid in claim.evidence: + if eid in seen: + continue + try: + store.get_source(eid) + except ArtifactNotFoundError: + continue + seen.add(eid) + ordered.append(eid) + return ordered + + +def enrich_page( + store: KBStore, + page_id: str, + *, + actor: str = ENRICH_ACTOR, + min_body_chars: int | None = None, + min_citations: int | None = None, + session_id: str | None = None, + dry_run: bool = False, + config: EnrichmentConfig | None = None, +) -> EnrichResult: + """Score `page_id` against the thin-page thresholds and, if it + qualifies, draft an enriched revision and file it as a page proposal. + + The addition is synthesized strictly from approved, non-retracted + claims already in the KB (via `synthesize.synthesize`) and appended to + the page's existing body, so a stub's hand-written text is never + discarded. Skips (no proposal filed) when the page is already above the + thresholds, or when no related approved claim exists to cite. + """ + cfg = config or load_config(store) + thresholds = _resolve_thresholds( + cfg, min_body_chars=min_body_chars, min_citations=min_citations, + ) + page = store.get_page(page_id) + + if not is_thin(page, thresholds): + return EnrichResult( + page_id=page_id, skipped_reason="page is above the thin-page thresholds", + ) + + query = f"{page.title}\n\n{page.body}".strip() + result = synthesize( + store, query=query, depth=_SYNTHESIZE_DEPTH, max_chars=_SYNTHESIZE_MAX_CHARS, + ) + addition = str(result["answer"]) + new_claim_ids = [str(c) for c in result["claims"]] + if not new_claim_ids: + return EnrichResult(page_id=page_id, skipped_reason="no related approved claims found") + + claim_ids = list(dict.fromkeys([*page.claims, *new_claim_ids])) + source_ids = list(dict.fromkeys([*page.sources, *_sources_for_claims(store, new_claim_ids)])) + body = f"{page.body.strip()}\n\n{addition}".strip() if page.body.strip() else addition + confidence = result["_meta"]["synthesis_confidence"] + rationale = ( + f"enriched from {len(new_claim_ids)} approved claim(s) via kb.synthesize " + f"(synthesis_confidence: {confidence})" + ) + + if dry_run: + return EnrichResult( + page_id=page_id, claim_ids=claim_ids, source_ids=source_ids, dry_run=True, + ) + + try: + proposal = propose_page( + store, + title=page.title, + body=body, + page_type=page.type, + claim_ids=claim_ids, + entity_ids=list(page.entities), + source_ids=source_ids, + tags=list(page.tags), + metadata=dict(page.metadata), + proposed_by=actor, + rationale=rationale, + slug_hint=page.id, + session_id=session_id, + ) + except ProposalError as e: + # e.g. a page kind's required fields tightened since the page was + # last approved — one page's rejection must not sink a batch run. + return EnrichResult( + page_id=page_id, claim_ids=claim_ids, source_ids=source_ids, + skipped_reason=str(e), + ) + return EnrichResult( + page_id=page_id, proposal_id=proposal.id, claim_ids=claim_ids, source_ids=source_ids, + ) + + +@dataclass +class EnrichPagesReport: + """Outcome of one `enrich_pages` batch pass over `store.list_pages()`.""" + + proposed: list[dict[str, Any]] = field(default_factory=list) + skipped: list[dict[str, Any]] = field(default_factory=list) + dry_run: bool = False + + def to_dict(self) -> dict[str, Any]: + return { + "proposed": self.proposed, + "skipped": self.skipped, + "dry_run": self.dry_run, + } + + +def enrich_pages( + store: KBStore, + *, + actor: str = ENRICH_ACTOR, + min_body_chars: int | None = None, + min_citations: int | None = None, + limit: int | None = None, + dry_run: bool = False, + session_id: str | None = None, + triggered_by: str | None = None, +) -> EnrichPagesReport: + """Scan `store.list_pages()`, select pages under the thin-page + thresholds, and file one `enrich_page` proposal per qualifying page. + + `limit` caps how many thin pages are processed in this run (a safety + valve against filing a large batch of proposals in one pass), applied + after thin-page selection. `dry_run` reports what would be proposed + without filing anything. + """ + cfg = load_config(store) + thresholds = _resolve_thresholds( + cfg, min_body_chars=min_body_chars, min_citations=min_citations, + ) + thin_pages = [p for p in store.list_pages() if is_thin(p, thresholds)] + if limit is not None: + thin_pages = thin_pages[:limit] + + report = EnrichPagesReport(dry_run=dry_run) + for page in thin_pages: + result = enrich_page( + store, page.id, actor=actor, + session_id=session_id, dry_run=dry_run, config=thresholds, + ) + if result.skipped_reason: + report.skipped.append({"page_id": page.id, "reason": result.skipped_reason}) + else: + report.proposed.append({ + "page_id": page.id, + "proposal_id": result.proposal_id or "(dry-run)", + "claim_ids": result.claim_ids, + "source_ids": result.source_ids, + }) + + if not dry_run: + audit_mod.log_event( + store.kb_dir, + event="enrich_pages.run", + actor=triggered_by or actor, + object_ids=[ + row["proposal_id"] for row in report.proposed + if row["proposal_id"] != "(dry-run)" + ], + data={"proposed": len(report.proposed), "skipped": len(report.skipped)}, + ) + return report diff --git a/src/vouch/health.py b/src/vouch/health.py index 0bc684cb..a73a6452 100644 --- a/src/vouch/health.py +++ b/src/vouch/health.py @@ -157,7 +157,7 @@ def rebuild_index(store: KBStore) -> dict: for p in store.list_pages(): index_db.index_page( conn, id=p.id, title=p.title, body=p.body, - type=p.type.value, tags=p.tags, + type=p.type, tags=p.tags, ) for e in store.list_entities(): index_db.index_entity( diff --git a/src/vouch/jsonl_server.py b/src/vouch/jsonl_server.py index f6f6a293..921cbb11 100644 --- a/src/vouch/jsonl_server.py +++ b/src/vouch/jsonl_server.py @@ -26,6 +26,7 @@ from typing import Any from . import audit, bundle, health +from . import enrich as enrich_mod from . import lifecycle as life from . import sessions as sess_mod from . import verify as verify_mod @@ -259,6 +260,17 @@ def _h_propose_page(p: dict) -> dict: return {"proposal_id": pr.id, "status": pr.status.value, "kind": pr.kind.value} +def _h_enrich_page(p: dict) -> dict: + result = enrich_mod.enrich_page( + _store(), p["page_id"], + min_body_chars=p.get("min_body_chars"), + min_citations=p.get("min_citations"), + session_id=p.get("session_id"), + dry_run=bool(p.get("dry_run", False)), + ) + return result.to_dict() + + def _h_propose_entity(p: dict) -> dict: pr = propose_entity( _store(), @@ -443,6 +455,7 @@ def _h_audit(p: dict) -> list[dict]: "kb.register_source_from_path": _h_register_source_from_path, "kb.propose_claim": _h_propose_claim, "kb.propose_page": _h_propose_page, + "kb.enrich_page": _h_enrich_page, "kb.propose_entity": _h_propose_entity, "kb.propose_relation": _h_propose_relation, "kb.approve": _h_approve, diff --git a/src/vouch/server.py b/src/vouch/server.py index 0665dc11..ac712823 100644 --- a/src/vouch/server.py +++ b/src/vouch/server.py @@ -18,6 +18,7 @@ from mcp.server.fastmcp import FastMCP from . import audit, bundle, health +from . import enrich as enrich_mod from . import lifecycle as life from . import sessions as sess_mod from . import verify as verify_mod @@ -195,7 +196,7 @@ def kb_read_relation(relation_id: str) -> dict[str, Any]: @mcp.tool() def kb_list_pages() -> list[dict[str, Any]]: return [ - {"id": p.id, "title": p.title, "type": p.type.value, "tags": p.tags} + {"id": p.id, "title": p.title, "type": p.type, "tags": p.tags} for p in _store().list_pages() ] @@ -337,6 +338,35 @@ def kb_propose_page( return _proposal_response(pr, dry_run) +@mcp.tool() +def kb_enrich_page( + page_id: str, + min_body_chars: int | None = None, + min_citations: int | None = None, + session_id: str | None = None, + dry_run: bool = False, +) -> dict[str, Any]: + """Draft an enriched revision of a thin page and file it as a proposal. + + Scores `page_id` against the thin-page thresholds (`enrichment.*` in + config.yaml, overridable here). If it qualifies, the addition is + synthesized strictly from approved, non-retracted claims already in the + KB (reusing `kb_synthesize`'s machinery) and filed as a PENDING page + proposal. Skips (files nothing) when the page is already above the + thresholds, or when no related approved claim exists to cite. Never + approves — a human clears the proposal with `kb_approve`. + """ + try: + result = enrich_mod.enrich_page( + _store(), page_id, + min_body_chars=min_body_chars, min_citations=min_citations, + session_id=session_id, dry_run=dry_run, + ) + except (ProposalError, ArtifactNotFoundError, ValueError) as e: + raise ValueError(str(e)) from e + return result.to_dict() + + @mcp.tool() def kb_propose_entity( name: str, diff --git a/src/vouch/storage.py b/src/vouch/storage.py index a0240b67..b8a80be9 100644 --- a/src/vouch/storage.py +++ b/src/vouch/storage.py @@ -125,6 +125,13 @@ def _starter_config() -> dict[str, Any]: ], }, "page_kinds": {}, + "enrichment": { + # A page is "thin" (eligible for kb.enrich_page) when its body is + # shorter than min_body_chars OR its combined claims+sources + # citation count is below min_citations. See enrich.py. + "min_body_chars": 200, + "min_citations": 2, + }, } @@ -367,6 +374,18 @@ def get_page(self, page_id: str) -> Page: raise ArtifactNotFoundError(f"page {page_id}") return _deserialize_page(p.read_text()) + def update_page(self, page: Page) -> Page: + """Overwrite an existing page on disk. Used by the vault-edit approve path. + + Parallel to `update_claim`: the caller is responsible for ensuring the + page id already exists (raises ArtifactNotFoundError otherwise). + """ + if not self._page_path(page.id).exists(): + raise ArtifactNotFoundError(f"page {page.id}") + self._page_path(page.id).write_text(_serialize_page(page)) + self._embed_and_store(kind="page", id=page.id, text=f"{page.title}\n\n{page.body}") + return page + def list_pages(self) -> list[Page]: pdir = self.kb_dir / "pages" if not pdir.is_dir(): diff --git a/tests/test_enrich_page.py b/tests/test_enrich_page.py new file mode 100644 index 00000000..9963862e --- /dev/null +++ b/tests/test_enrich_page.py @@ -0,0 +1,306 @@ +"""Tests for `vouch enrich-page` / `enrich-pages` — thin-page enrichment. + +Mirrors test_compile.py's shape: the mechanical guarantees under test are +that enrichment only ever proposes (never writes a durable page directly), +every added sentence carries a resolvable `[claim_id]` citation, and +thin-page selection respects the configured thresholds. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from vouch import capabilities, enrich, health +from vouch.enrich import EnrichmentConfig +from vouch.jsonl_server import HANDLERS, handle_request +from vouch.models import ProposalStatus +from vouch.proposals import approve, propose_claim, propose_page +from vouch.storage import ArtifactNotFoundError, KBStore + + +@pytest.fixture +def store(tmp_path: Path) -> KBStore: + return KBStore.init(tmp_path) + + +def _approved_claim(store: KBStore, text: str) -> str: + src = store.put_source(text.encode()) + pr = propose_claim(store, text=text, evidence=[src.id], proposed_by="agent-A") + claim = approve(store, pr.id, approved_by="human-B") + return claim.id + + +def _approved_page( + store: KBStore, title: str, body: str = "", *, claim_ids: list[str] | None = None, +): + pr = propose_page( + store, title=title, body=body, claim_ids=claim_ids or [], proposed_by="agent-A", + ) + return approve(store, pr.id, approved_by="human-B") + + +# --- thin-page selection ----------------------------------------------------- + + +def test_thin_by_short_body_gets_enrichment_proposal(store: KBStore) -> None: + c1 = _approved_claim(store, "retries are capped at three attempts") + c2 = _approved_claim(store, "retries use exponential backoff timing") + page = _approved_page(store, "Retries", "A stub.") + health.rebuild_index(store) + + result = enrich.enrich_page(store, page.id, min_body_chars=200, min_citations=2) + + assert result.proposal_id is not None + assert result.skipped_reason is None + assert set(result.claim_ids) >= {c1, c2} + pending = store.list_proposals(ProposalStatus.PENDING) + assert len(pending) == 1 + assert pending[0].kind.value == "page" + assert pending[0].payload["id"] == page.id + + +def test_thin_by_few_citations_gets_enrichment_proposal(store: KBStore) -> None: + c1 = _approved_claim(store, "deploys run through the staging gate") + long_body = "This page already has plenty of prose. " * 10 + page = _approved_page(store, "Deploys", long_body) + health.rebuild_index(store) + + result = enrich.enrich_page(store, page.id, min_body_chars=50, min_citations=2) + + assert result.proposal_id is not None + assert c1 in result.claim_ids + + +def test_page_above_thresholds_is_skipped(store: KBStore) -> None: + c1 = _approved_claim(store, "a fact") + c2 = _approved_claim(store, "another fact") + page = _approved_page(store, "Solid Page", "Plenty of body text here.", + claim_ids=[c1, c2]) + health.rebuild_index(store) + + result = enrich.enrich_page(store, page.id, min_body_chars=10, min_citations=2) + + assert result.proposal_id is None + assert result.skipped_reason == "page is above the thin-page thresholds" + assert store.list_proposals(ProposalStatus.PENDING) == [] + + +def test_page_with_no_related_claims_is_skipped(store: KBStore) -> None: + # an unrelated claim exists so the kb isn't empty, but nothing matches + # this page's topic. + _approved_claim(store, "kubernetes networking uses cni plugins") + page = _approved_page(store, "Payroll Rounding", "x") + health.rebuild_index(store) + + result = enrich.enrich_page(store, page.id) + + assert result.proposal_id is None + assert result.skipped_reason == "no related approved claims found" + assert store.list_proposals(ProposalStatus.PENDING) == [] + + +def test_missing_page_raises(store: KBStore) -> None: + with pytest.raises(ArtifactNotFoundError): + enrich.enrich_page(store, "no-such-page") + + +# --- propose, never write ---------------------------------------------------- + + +def test_enrichment_never_writes_durably_until_approved(store: KBStore) -> None: + _approved_claim(store, "billing retries cap at three attempts") + page = _approved_page(store, "Billing", "x") + health.rebuild_index(store) + before = store.get_page(page.id).body + + result = enrich.enrich_page(store, page.id) + + assert store.get_page(page.id).body == before + assert result.proposal_id is not None + + # a human approval (different actor) revises the existing page in place. + revised = approve(store, result.proposal_id, approved_by="human-C") + assert revised.id == page.id + assert store.get_page(page.id).body != before + assert len(store.list_pages()) == 1 # revision, not a duplicate page + + +def test_dry_run_files_nothing(store: KBStore) -> None: + _approved_claim(store, "retries are capped at three attempts") + page = _approved_page(store, "Retries", "A stub.") + health.rebuild_index(store) + + result = enrich.enrich_page(store, page.id, dry_run=True) + + assert result.dry_run + assert result.claim_ids + assert store.list_proposals(ProposalStatus.PENDING) == [] + + +# --- citation traceability ---------------------------------------------------- + + +def test_every_added_sentence_carries_a_resolvable_citation(store: KBStore) -> None: + ids = [ + _approved_claim(store, "auth uses short-lived jwt access tokens"), + _approved_claim(store, "auth refresh tokens rotate on every use"), + ] + page = _approved_page(store, "Auth", "A stub about auth.") + health.rebuild_index(store) + + result = enrich.enrich_page(store, page.id, dry_run=True) + + assert set(result.claim_ids) & set(ids) + result2 = enrich.enrich_page(store, page.id) + proposal = store.get_proposal(result2.proposal_id) + body = proposal.payload["body"] + assert "A stub about auth." in body # original prose preserved + for cid in result2.claim_ids: + assert f"[{cid}]" in body + assert store.get_claim(cid).id == cid + + +# --- config ------------------------------------------------------------------- + + +def test_load_config_reads_enrichment_stanza(store: KBStore) -> None: + store.config_path.write_text( + store.config_path.read_text(encoding="utf-8") + + "\nenrichment:\n min_body_chars: 500\n min_citations: 4\n", + encoding="utf-8", + ) + cfg = enrich.load_config(store) + assert cfg.min_body_chars == 500 + assert cfg.min_citations == 4 + + +def test_load_config_defaults_when_absent(store: KBStore) -> None: + cfg = enrich.load_config(store) + assert cfg.min_body_chars == enrich.DEFAULT_MIN_BODY_CHARS + assert cfg.min_citations == enrich.DEFAULT_MIN_CITATIONS + + +def test_load_config_bad_values_fall_back_to_defaults(store: KBStore) -> None: + store.config_path.write_text( + store.config_path.read_text(encoding="utf-8") + + "\nenrichment:\n min_body_chars: two-hundred\n min_citations:\n", + encoding="utf-8", + ) + cfg = enrich.load_config(store) + assert cfg.min_body_chars == enrich.DEFAULT_MIN_BODY_CHARS + assert cfg.min_citations == enrich.DEFAULT_MIN_CITATIONS + + +def test_is_thin_either_threshold(store: KBStore) -> None: + cfg = EnrichmentConfig(min_body_chars=100, min_citations=2) + page = _approved_page(store, "Short", "x" * 50) + assert enrich.is_thin(page, cfg) + + c1 = _approved_claim(store, "fact one") + c2 = _approved_claim(store, "fact two") + page2 = _approved_page(store, "LongButUncited", "x" * 200) + assert enrich.is_thin(page2, cfg) # 0 citations < 2 + + page3 = _approved_page(store, "CitedButShort", "x" * 50, claim_ids=[c1, c2]) + assert enrich.is_thin(page3, cfg) # body still short + + page4 = _approved_page(store, "Solid", "x" * 200, claim_ids=[c1, c2]) + assert not enrich.is_thin(page4, cfg) + + +# --- batch pass: enrich_pages ------------------------------------------------- + + +def test_enrich_pages_dry_run_lists_without_filing(store: KBStore) -> None: + _approved_claim(store, "retries are capped at three attempts") + solid_claim = _approved_claim(store, "solid page fact") + thin = _approved_page(store, "Retries", "A stub.") + solid = _approved_page( + store, "Solid", "Plenty of prose here to clear the threshold easily.", + claim_ids=[solid_claim], + ) + health.rebuild_index(store) + + report = enrich.enrich_pages(store, min_body_chars=20, min_citations=1, dry_run=True) + + assert report.dry_run + proposed_ids = {row["page_id"] for row in report.proposed} + assert thin.id in proposed_ids + assert solid.id not in proposed_ids + assert store.list_proposals(ProposalStatus.PENDING) == [] + + +def test_enrich_pages_respects_limit(store: KBStore) -> None: + _approved_claim(store, "a shared fact about the system") + pages = [ + _approved_page(store, f"Stub {i}", "x") for i in range(3) + ] + health.rebuild_index(store) + + report = enrich.enrich_pages(store, min_body_chars=200, min_citations=2, limit=1) + + assert len(report.proposed) + len(report.skipped) == 1 + assert {p.id for p in pages} # sanity: pages exist + + +def test_enrich_pages_logs_audit_event(store: KBStore) -> None: + from vouch import audit as audit_mod + + _approved_claim(store, "a fact for the audit test") + _approved_page(store, "Stub", "x") + health.rebuild_index(store) + + enrich.enrich_pages( + store, min_body_chars=200, min_citations=2, triggered_by="human-reviewer", + ) + events = [e for e in audit_mod.read_events(store.kb_dir) + if e.event == "enrich_pages.run"] + assert len(events) == 1 + assert events[0].actor == "human-reviewer" + + # dry runs mutate nothing and log nothing. + enrich.enrich_pages( + store, min_body_chars=200, min_citations=2, dry_run=True, + triggered_by="human-reviewer", + ) + events = [e for e in audit_mod.read_events(store.kb_dir) + if e.event == "enrich_pages.run"] + assert len(events) == 1 + + +# --- wire surfaces ------------------------------------------------------------- + + +def test_capabilities_lists_enrich_page() -> None: + assert "kb.enrich_page" in capabilities.capabilities().methods + assert "kb.enrich_page" in HANDLERS + + +def test_jsonl_kb_enrich_page_files_proposal( + store: KBStore, monkeypatch: pytest.MonkeyPatch, +) -> None: + _approved_claim(store, "retries are capped at three attempts") + page = _approved_page(store, "Retries", "A stub.") + health.rebuild_index(store) + monkeypatch.chdir(store.root) + + resp = handle_request({ + "id": "r1", "method": "kb.enrich_page", + "params": {"page_id": page.id, "min_body_chars": 200, "min_citations": 2}, + }) + + assert resp["ok"] + assert resp["result"]["proposed"] + assert store.list_pages()[0].body == "A stub." # unchanged — still just a proposal + + +def test_jsonl_kb_enrich_page_missing_page_is_clean_error( + store: KBStore, monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.chdir(store.root) + resp = handle_request({ + "id": "r2", "method": "kb.enrich_page", "params": {"page_id": "no-such-page"}, + }) + assert not resp["ok"]