diff --git a/CHANGELOG.md b/CHANGELOG.md index c61ce193..addf62ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ All notable changes to vouch are documented here. Format follows ## [Unreleased] ### Added +- `vouch compile` — the llm-wiki ingest pass: a deployment-configured LLM + (`compile.llm_cmd` in `.vouch/config.yaml`) drafts topic pages from live + approved claims; every inline `[claim: …]` marker and `[[wikilink]]` is + verified mechanically against the store, and surviving drafts are filed as + PENDING page proposals by the `wiki-compiler` actor. never approves — the + review gate is the ingest review. `--dry-run`, `--max-pages`, `--llm-cmd`, + `--json`. see `docs/compile.md`. +- review-ui: a **compile wiki** button on the queue (shown once + `compile.llm_cmd` is configured) runs the same ingest pass and lands the + drafts in the queue; success and per-draft drop counts surface as a notice. - company-brain template: `vouch init --template company-brain` declares typed record kinds (contact, org, project-record, meeting-notes, followup, decision-record, voice) as `page_kinds` config and seeds a cited guide diff --git a/README.md b/README.md index a3ef866b..6084ce26 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,12 @@ Still alpha — surface is small on purpose; expect breaking changes pre-1.0. > **Built for Gittensor (SN74) miners.** Mining subnet 74 means landing merged PRs across a whitelist of repos that keeps shuffling — which means re-investigating each repo's codebase and merge bar every session your agent opens. vouch auto-captures what a session works out, you approve what's worth keeping, and the next session recalls it: less re-discovery, more merged PRs. → **[docs/gittensor.md](docs/gittensor.md)** +## Watch it work (110 seconds) + +[![vouch demo — capture, summarize, approve, compile, recall](docs/img/how-it-works-poster.jpg)](docs/vouch-how-it-works.mp4) + +**[▶ docs/vouch-how-it-works.mp4](docs/vouch-how-it-works.mp4)** — captured live from the review console, no mockups: a Claude Code session auto-captures itself, one click has an LLM summarize what it meant, a human approves it at the gate, **`vouch compile`** distills the approved claims into cited topic pages (drafted by a real LLM, every `[claim: …]` citation machine-verified, still gated), and the film ends on the actual `vouch recall` digest — the knowledge the video just built, injected into the next session's first turn. + ## Why this exists Four opinionated choices distinguish vouch from the neighbours: diff --git a/docs/compile.md b/docs/compile.md new file mode 100644 index 00000000..bae72c6c --- /dev/null +++ b/docs/compile.md @@ -0,0 +1,77 @@ +# vouch compile — the llm-wiki ingest pass + +`vouch compile` turns approved, cited claims into drafted **topic pages** and +files them as pending proposals. It is the "compile" in *stop re-deriving, +start compiling*: sessions and sources feed claims through the review gate; +compile distills those claims into the small, durable wiki a future agent (or +human) reads first. + +``` + sessions / sources claims topic pages + ───────────────────▶ gate ───────────▶ compile ───────────▶ gate ──▶ wiki + (capture) (approve) (LLM drafts) (approve) +``` + +## Setup + +Compile needs a deployment-configured LLM command in `.vouch/config.yaml`: + +```yaml +compile: + llm_cmd: "claude -p --model sonnet" # reads prompt on stdin, prints JSON + max_pages: 5 # optional, default 5 + timeout_seconds: 180 # optional, default 180 +``` + +vouch ships no model dependency; any command that reads a prompt on stdin and +prints JSON on stdout works. + +## Run it + +```bash +vouch compile # draft pages, file as pending proposals +vouch compile --dry-run # show what would be drafted, file nothing +vouch compile --json # machine-readable report +vouch review # the human half: approve / reject each draft +``` + +Or click **compile wiki** in the review UI (`vouch review-ui`) — the button +appears once `compile.llm_cmd` is configured, and the drafts land in the same +queue, marked `by wiki-compiler`. + +## What the validator enforces + +Drafts are LLM prose, so every citation is verified mechanically before a +proposal is filed. A draft is dropped (and reported) when: + +- it lists a claim id that doesn't exist or is retracted, +- its body cites `[claim: x]` for a claim it doesn't list, +- it cites no claims at all, +- a `[[wikilink]]` doesn't resolve to an existing page or a *surviving* page + in the same batch (drops cascade: if a linked sibling is dropped, the + linking draft drops too rather than shipping a dangling link), +- its title collides with an existing page or a page draft already pending + review (`approve()` would route a colliding id through `update_page` and + silently overwrite — so collisions never reach the queue; this also makes + re-running compile idempotent instead of queueing duplicates), +- its type is `session` or `log` (raw material, not topics), +- it exceeds `max_pages`. + +The compiler proposes; it never approves. Proposals are filed by the +`wiki-compiler` 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 run appends a `compile.run` audit event attributed +to whoever triggered it (CLI user, or the review-ui token label), with the +filed proposal ids. + +The review UI runs one compile at a time per KB; a second click while one is +running comes back with a "already running" notice instead of stacking LLM +runs. + +## Current limits + +- **Creates only.** Compiled *updates* to existing pages are a future + feature; today a colliding draft is dropped at validation (see above) and + the prompt tells the compiler to skip taken topics. +- One pass over the whole KB. Compile inlines all live claims into the + prompt; very large KBs will want an incremental "since last compile" mode. diff --git a/docs/img/how-it-works-poster.jpg b/docs/img/how-it-works-poster.jpg new file mode 100644 index 00000000..6aaaf48e Binary files /dev/null and b/docs/img/how-it-works-poster.jpg differ diff --git a/docs/vouch-how-it-works.mp4 b/docs/vouch-how-it-works.mp4 new file mode 100644 index 00000000..18258efc Binary files /dev/null and b/docs/vouch-how-it-works.mp4 differ diff --git a/src/vouch/capabilities.py b/src/vouch/capabilities.py index 860bf084..3fd21c75 100644 --- a/src/vouch/capabilities.py +++ b/src/vouch/capabilities.py @@ -72,6 +72,7 @@ "kb.provenance_rebuild", "kb.detect_themes", "kb.propose_theme", + "kb.compile", ] diff --git a/src/vouch/cli.py b/src/vouch/cli.py index 59d5ef90..cea7ac6b 100644 --- a/src/vouch/cli.py +++ b/src/vouch/cli.py @@ -24,6 +24,7 @@ from . import __version__, bundle, health, volunteer_context from . import audit as audit_mod from . import capture as capture_mod +from . import compile as compile_mod from . import digest as digest_mod from . import fetch as fetch_mod from . import inbox as inbox_mod @@ -2026,6 +2027,46 @@ def recall_cmd() -> None: click.echo(digest) +@cli.command(name="compile") +@click.option("--dry-run", is_flag=True, help="Draft and validate; file nothing.") +@click.option("--max-pages", type=int, default=None, + help="Cap drafted pages (default: compile.max_pages, 5).") +@click.option("--llm-cmd", default=None, + help="Override compile.llm_cmd from config.yaml for this run.") +@click.option("--json", "as_json", is_flag=True, help="Machine-readable report.") +def compile_cmd(dry_run: bool, max_pages: int | None, + llm_cmd: str | None, as_json: bool) -> None: + """Compile approved claims into topic-page proposals (llm-wiki ingest). + + Runs the deployment-configured LLM (compile.llm_cmd) over the live + approved claims, validates every citation in the drafts, and files the + survivors as pending page proposals. Approval stays a separate human + step (`vouch review`). + """ + store = _load_store() + actor = os.environ.get("VOUCH_AGENT") or compile_mod.COMPILE_ACTOR + try: + report = compile_mod.compile_kb( + store, actor=actor, triggered_by=_whoami(), llm_cmd=llm_cmd, + max_pages=max_pages, dry_run=dry_run, + ) + except compile_mod.CompileError as e: + raise click.ClickException(str(e)) from e + if as_json: + _emit_json(report.to_dict()) + return + verb = "would propose" if dry_run else "proposed" + _echo(f"{verb} {len(report.proposed)} page draft(s):") + for row in report.proposed: + _echo(f" • {row['proposal_id']} {row['title']}") + if report.dropped: + _echo(f"dropped {len(report.dropped)}:") + for row in report.dropped: + _echo(f" • {row['title']} — {row['reason']}") + if report.proposed and not dry_run: + _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/compile.py b/src/vouch/compile.py new file mode 100644 index 00000000..df7b6c2d --- /dev/null +++ b/src/vouch/compile.py @@ -0,0 +1,403 @@ +"""Compile approved knowledge into reviewed wiki topic pages. + +The llm-wiki ingest pass: hand a deployment-configured LLM command the live +approved claims plus the current page list, receive drafted topic pages as +structured JSON, validate every citation mechanically, and file the survivors +as PENDING page proposals. Never calls ``approve()`` — the review gate stays +intact, and the human decides page by page. + +Division of labor mirrors Karpathy's llm-wiki: the LLM drafts and cross-links +the articles; code verifies that every ``[claim: id]`` marker and ``[[link]]`` +resolves; the human review is the ingest gate. A draft with an unverifiable +citation is dropped and reported, not repaired — the compiler must not invent +provenance. + +The LLM command is deployment config (``compile.llm_cmd`` in +``.vouch/config.yaml``), same pattern as capture/recall: vouch ships no model +dependency and the KB never records which model drafted a page — the audit +trail cares who *approved* it. +""" + +from __future__ import annotations + +import json +import re +import subprocess +import tempfile +from dataclasses import dataclass, field +from typing import Any + +import yaml + +from . import audit as audit_mod +from .context import _RETRACTED_CLAIM_STATUSES +from .models import ProposalStatus +from .proposals import ProposalError, _slugify, propose_page +from .storage import ArtifactNotFoundError, KBStore + +DEFAULT_MAX_PAGES = 5 +DEFAULT_TIMEOUT_SECONDS = 180.0 + +# The proposer identity for compiled drafts. Deliberately NOT the human +# running the command: the default review gate refuses self-approval, so the +# reviewer who approves a compiled page must be a different actor than the +# proposer. VOUCH_AGENT still wins when set (multi-agent attribution). +COMPILE_ACTOR = "wiki-compiler" + +# Raw-material page kinds the compiler must not draft: sessions and logs are +# feedstock for compilation, never its output. +_FORBIDDEN_TYPES = frozenset({"session", "log"}) + +_WIKILINK_RE = re.compile(r"\[\[([^\]|#]+)") +_CLAIM_MARKER_RE = re.compile(r"\[claim:\s*([^\]]+)\]") +_FENCE_RE = re.compile(r"^```[a-zA-Z]*\n|\n```$") + + +class CompileError(Exception): + """Compile could not run at all (config, LLM, or output-shape failure).""" + + +@dataclass(frozen=True) +class CompileConfig: + llm_cmd: str | None = None + max_pages: int = DEFAULT_MAX_PAGES + timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS + + +def _coerce(value: Any, default: Any, cast: Any) -> Any: + # A config typo (max_pages: five) must degrade to the default, not take + # down every caller — the web queue reads this config on each render. + try: + return cast(value) + except (TypeError, ValueError): + return default + + +def load_config(store: KBStore) -> CompileConfig: + """Read ``compile:`` 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 CompileConfig() + if not isinstance(loaded, dict): + return CompileConfig() + raw = loaded.get("compile") + if not isinstance(raw, dict): + return CompileConfig() + cmd = raw.get("llm_cmd") + return CompileConfig( + llm_cmd=str(cmd) if cmd else None, + max_pages=_coerce( + raw.get("max_pages", DEFAULT_MAX_PAGES), DEFAULT_MAX_PAGES, int, + ), + timeout_seconds=_coerce( + raw.get("timeout_seconds", DEFAULT_TIMEOUT_SECONDS), + DEFAULT_TIMEOUT_SECONDS, float, + ), + ) + + +@dataclass +class CompileReport: + proposed: list[dict[str, str]] = field(default_factory=list) + dropped: list[dict[str, str]] = field(default_factory=list) + drafts: list[dict[str, Any]] = field(default_factory=list) + dry_run: bool = False + + def to_dict(self) -> dict[str, Any]: + return { + "proposed": self.proposed, + "dropped": self.dropped, + "draft_count": len(self.drafts), + "dry_run": self.dry_run, + } + + +def _pending_page_names(store: KBStore) -> set[str]: + """Lowercased titles + ids of page proposals already awaiting review.""" + names: set[str] = set() + for prop in store.list_proposals(ProposalStatus.PENDING): + if prop.kind.value != "page": + continue + for key in ("title", "id"): + value = str(prop.payload.get(key) or "").strip().lower() + if value: + names.add(value) + return names + + +def build_prompt(store: KBStore, *, max_pages: int) -> str: + """Assemble the self-contained maintainer prompt. + + The whole working set (live claims + page inventory) is inlined rather + than retrieved: compile is an ingest pass over the KB, and a KB small + enough to review by hand is small enough to hand to the compiler whole. + """ + claims = [ + c for c in store.list_claims() + if c.status not in _RETRACTED_CLAIM_STATUSES + ] + if not claims: + raise CompileError("nothing to compile: the KB has no live approved claims") + pages = store.list_pages() + pending = _pending_page_names(store) + + lines = [ + "You are the wiki maintainer for this project's knowledge base. You", + "compile approved, cited claims into a small set of durable topic", + "pages (concepts, workflows, decisions) that a future agent or human", + "reads first. Humans rarely write pages; you do.", + "", + "APPROVED CLAIMS (id: text):", + ] + for c in claims: + lines.append(f"- {c.id}: {c.text}") + lines += ["", "TAKEN TOPICS (existing pages or drafts already awaiting " + "review) — do NOT redraft any of these:"] + taken_lines = [f"- {p.id}: {p.title} [{p.type}]" for p in pages] + taken_lines += [f"- {name} [pending review]" for name in sorted(pending)] + lines += taken_lines or ["- (none)"] + lines += [ + "", + "RULES", + f"- Draft at most {max_pages} genuinely NEW topic pages. Skip topics", + " already taken; page updates are not supported yet.", + "- Prefer durable topics over chronology. Never draft a page about a", + " session itself; session records are raw material.", + "- Body: 80-200 words of synthesized markdown prose. After each", + " load-bearing statement add an inline citation marker", + " [claim: ] using ONLY ids from the list above.", + "- Cross-reference other pages with [[]] wikilinks, only", + " when genuinely related, and only to existing pages or pages in", + " this same batch.", + "- Allowed \"type\" values: concept, workflow, decision, report, index.", + "", + "OUTPUT: print ONLY a JSON array, no code fences, no commentary.", + "Each element: {\"title\": str, \"type\": str, \"body\": str,", + " \"claims\": [claim-id, ...]}", + ] + return "\n".join(lines) + + +def run_llm(llm_cmd: str, prompt: str, *, timeout_seconds: float) -> str: + """Run the configured LLM command with the prompt on stdin. + + Runs in a throwaway temp directory: an LLM CLI that discovers per-project + hooks or MCP servers from its cwd (claude -p does) must not fire this + project's capture pipeline or connect back to this KB while compiling it. + + Explicit UTF-8 on both pipe directions — the default follows the locale + (Latin-1 on some hosts, see storage.py), which would crash on the first + em-dash in a claim or silently mojibake the drafted bodies. ``replace`` + on decode so a stray invalid byte from the LLM surfaces as a visible + replacement char in review rather than an exception. + """ + with tempfile.TemporaryDirectory(prefix="vouch-compile-") as tmp: + try: + proc = subprocess.run( + llm_cmd, shell=True, cwd=tmp, + input=prompt, capture_output=True, text=True, + encoding="utf-8", errors="replace", + timeout=timeout_seconds, + ) + except subprocess.TimeoutExpired as e: + raise CompileError( + f"compile.llm_cmd timed out after {timeout_seconds:.0f}s" + ) from e + if proc.returncode != 0: + detail = (proc.stderr or proc.stdout or "").strip()[:400] + raise CompileError(f"compile.llm_cmd failed ({proc.returncode}): {detail}") + return proc.stdout + + +def parse_drafts(raw: str) -> list[dict[str, Any]]: + text = raw.strip() + text = _FENCE_RE.sub("", text).strip() + try: + data = json.loads(text) + except json.JSONDecodeError as e: + raise CompileError(f"compiler output is not valid JSON: {e}") from e + if not isinstance(data, list): + raise CompileError("compiler output must be a JSON array of pages") + for item in data: + if not isinstance(item, dict): + # a list of strings is a common LLM shape failure; surfacing it + # beats reporting an empty-but-successful compile. + raise CompileError( + "compiler output must be a JSON array of page objects, " + f"got element of type {type(item).__name__}" + ) + return list(data) + + +def _draft_problem( + store: KBStore, + draft: dict[str, Any], + *, + taken_names: set[str], +) -> str | None: + """Mechanical validation minus wikilinks (those need the surviving batch). + + Returns a drop reason, or None when clean. + """ + title = str(draft.get("title") or "").strip() + body = str(draft.get("body") or "").strip() + page_type = str(draft.get("type") or "").strip().lower() + if not title: + return "draft has no title" + if not body: + return "draft has no body" + if page_type in _FORBIDDEN_TYPES: + return f"type {page_type!r} is raw material, not a topic page" + + # collision guard: approve() routes an existing page id through + # update_page (the vault-edit path), so a colliding "new" draft would + # silently overwrite the page on approval. drop it here instead; + # compiled updates are a future feature, not an accident. + if title.lower() in taken_names or _slugify(title) in taken_names: + return f"page for {title!r} already exists or is pending review" + + listed = [str(c) for c in draft.get("claims") or []] + if not listed: + return "draft cites no claims" + live_ids: set[str] = set() + for cid in listed: + try: + claim = store.get_claim(cid) + except ArtifactNotFoundError: + return f"unknown claim id: {cid}" + if claim.status in _RETRACTED_CLAIM_STATUSES: + return f"claim {cid} is retracted" + live_ids.add(cid) + + # every inline [claim: …] marker must be backed by a listed, live claim — + # a body citing a claim the page doesn't link is invented provenance. + for marker in _CLAIM_MARKER_RE.findall(body): + cid = marker.strip() + if cid not in live_ids: + return f"body cites unlisted claim: {cid}" + return None + + +def _first_dangling_link(body: str, known: set[str]) -> str | None: + for target in _WIKILINK_RE.findall(body): + name = target.strip() + if name and name.lower() not in known: + return name + return None + + +def compile_kb( + store: KBStore, + *, + actor: str = COMPILE_ACTOR, + triggered_by: str | None = None, + llm_cmd: str | None = None, + max_pages: int | None = None, + dry_run: bool = False, + session_id: str | None = None, + config: CompileConfig | None = None, +) -> CompileReport: + """One ingest pass: draft topic pages from live claims, file as proposals. + + ``dry_run`` parses and validates but files nothing. Raises + :class:`CompileError` when the pass cannot run at all; per-draft failures + land in ``report.dropped`` instead so one bad draft never sinks the batch. + ``triggered_by`` is the human (or token label) who initiated the run — + recorded in the audit log so web-triggered compiles stay attributable. + """ + cfg = config or load_config(store) + cmd = llm_cmd or cfg.llm_cmd + if not cmd: + raise CompileError( + "compile.llm_cmd is not configured — set it in .vouch/config.yaml, " + "e.g.\ncompile:\n llm_cmd: \"claude -p --model sonnet\"" + ) + cap = max_pages if max_pages is not None else cfg.max_pages + + prompt = build_prompt(store, max_pages=cap) + drafts = parse_drafts(run_llm(cmd, prompt, timeout_seconds=cfg.timeout_seconds)) + + report = CompileReport(drafts=drafts, dry_run=dry_run) + + existing = store.list_pages() + taken_names = {p.title.strip().lower() for p in existing} + taken_names |= {p.id.strip().lower() for p in existing} + taken_names |= _pending_page_names(store) + + # phase 1: per-draft validation + the cap. cap first-come: a draft past + # the cap is dropped even if an earlier one falls later, so the outcome + # doesn't depend on drop order. + survivors: list[tuple[dict[str, Any], str]] = [] + for i, draft in enumerate(drafts): + title = str(draft.get("title") or f"draft {i}").strip() + if len(survivors) >= cap: + report.dropped.append({"title": title, "reason": f"over max_pages={cap}"}) + continue + problem = _draft_problem(store, draft, taken_names=taken_names) + if problem: + report.dropped.append({"title": title, "reason": problem}) + continue + survivors.append((draft, title)) + + # phase 2: wikilinks resolve against existing pages + the *surviving* + # batch, to a fixpoint — dropping a draft may dangle a link in another, + # so iterate until stable. filing a draft whose [[link]] points at a + # sibling that was just dropped would ship the dangling link the + # validator exists to prevent. + known_static = {p.title.strip().lower() for p in existing} + known_static |= {p.id.strip().lower() for p in existing} + changed = True + while changed: + changed = False + known = known_static | {t.lower() for _, t in survivors} + for entry in list(survivors): + draft, title = entry + dangling = _first_dangling_link(str(draft.get("body") or ""), known) + if dangling is not None: + survivors.remove(entry) + report.dropped.append({ + "title": title, + "reason": f"unresolved wikilink: [[{dangling}]]", + }) + changed = True + + for draft, title in survivors: + if dry_run: + report.proposed.append({"title": title, "proposal_id": "(dry-run)"}) + continue + try: + proposal = propose_page( + store, + title=title, + body=str(draft["body"]).strip(), + page_type=str(draft.get("type") or "concept").strip().lower(), + claim_ids=[str(c) for c in draft.get("claims") or []], + proposed_by=actor, + tags=["wiki", "compiled"], + session_id=session_id, + rationale="compiled from approved claims; every inline " + "citation was verified against the store", + ) + except ProposalError as e: + report.dropped.append({"title": title, "reason": str(e)}) + continue + report.proposed.append({ + "title": title, + "proposal_id": proposal.id, + "page_id": str(proposal.payload.get("id", "")), + }) + + if not dry_run: + audit_mod.log_event( + store.kb_dir, + event="compile.run", + actor=triggered_by or actor, + object_ids=[row["proposal_id"] for row in report.proposed], + data={ + "proposed": len(report.proposed), + "dropped": len(report.dropped), + "proposer": actor, + }, + ) + return report diff --git a/src/vouch/jsonl_server.py b/src/vouch/jsonl_server.py index 55059950..5e3b62b7 100644 --- a/src/vouch/jsonl_server.py +++ b/src/vouch/jsonl_server.py @@ -29,6 +29,7 @@ import yaml from . import audit, bundle, health, volunteer_context +from . import compile as compile_mod from . import digest as digest_mod from . import lifecycle as life from . import metrics as metrics_mod @@ -366,6 +367,22 @@ def _h_propose_page(p: dict) -> dict: return {"proposal_id": pr.id, "status": pr.status.value, "kind": pr.kind.value} +def _h_compile(p: dict) -> dict: + try: + report = compile_mod.compile_kb( + _store(), triggered_by=_agent(), + max_pages=p.get("max_pages"), + dry_run=bool(p.get("dry_run", False)), + session_id=p.get("session_id"), + ) + except compile_mod.CompileError as e: + # config/LLM/output-shape failures are caller-visible conditions, + # not server faults — surface them on the ValueError path so the + # envelope carries a clean message instead of internal_error. + raise ValueError(str(e)) from e + return report.to_dict() + + def _h_propose_entity(p: dict) -> dict: pr = propose_entity( _store(), @@ -716,6 +733,7 @@ def _h_propose_theme(p: dict) -> dict: "kb.register_source_from_path": _h_register_source_from_path, "kb.propose_claim": _h_propose_claim, "kb.propose_page": _h_propose_page, + "kb.compile": _h_compile, "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 6095f002..8cabee08 100644 --- a/src/vouch/server.py +++ b/src/vouch/server.py @@ -20,6 +20,7 @@ from mcp.server.fastmcp import FastMCP from . import audit, bundle, health, volunteer_context +from . import compile as compile_mod from . import digest as digest_mod from . import lifecycle as life from . import metrics as metrics_mod @@ -474,6 +475,29 @@ def kb_propose_page( return _proposal_response(pr, dry_run) +@mcp.tool() +def kb_compile( + max_pages: int | None = None, + dry_run: bool = False, + session_id: str | None = None, +) -> dict[str, Any]: + """Compile live approved claims into topic-page proposals. + + Runs the deployment-configured LLM (compile.llm_cmd in config.yaml), + validates every citation in the drafts, and files survivors as PENDING + page proposals by the wiki-compiler actor. Long-running (the LLM call is + synchronous); never approves. + """ + try: + report = compile_mod.compile_kb( + _store(), triggered_by=_agent(), + max_pages=max_pages, dry_run=dry_run, session_id=session_id, + ) + except compile_mod.CompileError as e: + raise ValueError(str(e)) from e + return report.to_dict() + + @mcp.tool() def kb_propose_entity( name: str, diff --git a/src/vouch/web/server.py b/src/vouch/web/server.py index ed51af1b..597e15ba 100644 --- a/src/vouch/web/server.py +++ b/src/vouch/web/server.py @@ -41,6 +41,7 @@ from dataclasses import dataclass, field from pathlib import Path from typing import Any +from urllib.parse import quote from fastapi import ( Depends, @@ -57,6 +58,7 @@ from starlette.concurrency import run_in_threadpool from .. import audit as audit_mod +from .. import compile as compile_mod from .. import lifecycle as life from .. import proposals as proposals_mod from ..models import Proposal, ProposalStatus @@ -387,7 +389,13 @@ def _row(p: Proposal) -> dict[str, Any]: } @app.get("/", response_class=HTMLResponse, dependencies=guarded) - def queue(request: Request, page: int = 1) -> Any: + def queue( + request: Request, + page: int = 1, + compiled: int | None = None, + dropped: int | None = None, + compile_error: str | None = None, + ) -> Any: proposals, page, pages, total = _pending_page(store, page, page_size) return _tmpl(request, "queue.html", { "items": [_row(p) for p in proposals], @@ -396,6 +404,13 @@ def queue(request: Request, page: int = 1) -> Any: "pages": pages, "page_size": page_size, "active": "queue", + # compile is available only when the deployment configured an LLM + # command; re-read per request so a config.yaml edit shows up + # without restarting the server. + "compile_enabled": compile_mod.load_config(store).llm_cmd is not None, + "compiled": compiled, + "compile_dropped": dropped, + "compile_error": compile_error, }) # --- claim detail --- @@ -513,6 +528,48 @@ async def reject(proposal_id: str, reason: str = Form(...)) -> Any: await _notify("queue", action="reject", proposal_id=proposal_id) return RedirectResponse(url="/", status_code=303) + # One compile at a time per KB: each run holds a threadpool thread and a + # live LLM subprocess for up to compile.timeout_seconds, so an unguarded + # button (or a double-click) could exhaust the pool and run up LLM spend. + compile_lock = asyncio.Lock() + app.state.compile_lock = compile_lock + + @app.post("/compile", dependencies=guarded) + async def compile_wiki() -> Any: + """Run the llm-wiki ingest pass and land its drafts in this queue. + + Compile only *proposes* — the drafts appear as pending page proposals + for the reviewer to decide, so this button adds to the queue and never + writes a durable artifact. Synchronous by design: the reviewer clicked + it and is waiting for the drafts; the LLM call is offloaded to the + threadpool so other reviewers' requests aren't blocked meanwhile. + """ + cfg = compile_mod.load_config(store) + if cfg.llm_cmd is None: + raise HTTPException( + status_code=400, + detail="compile.llm_cmd is not configured in .vouch/config.yaml", + ) + if compile_lock.locked(): + reason = quote("a compile is already running — refresh in a moment") + return RedirectResponse(url=f"/?compile_error={reason}", status_code=303) + async with compile_lock: + try: + report = await run_in_threadpool( + compile_mod.compile_kb, store, + config=cfg, triggered_by=reviewer(), + ) + except compile_mod.CompileError as e: + reason = quote(str(e)[:200]) + return RedirectResponse( + url=f"/?compile_error={reason}", status_code=303, + ) + await _notify("queue", action="compile", proposed=len(report.proposed)) + return RedirectResponse( + url=f"/?compiled={len(report.proposed)}&dropped={len(report.dropped)}", + status_code=303, + ) + @app.post("/contradict/{claim_id}", dependencies=guarded) async def contradict(claim_id: str, against: str = Form(...)) -> Any: """Mark two durable claims as contradicting each other — a gate action diff --git a/src/vouch/web/static/app.css b/src/vouch/web/static/app.css index d957dc6a..a69df0b3 100644 --- a/src/vouch/web/static/app.css +++ b/src/vouch/web/static/app.css @@ -145,6 +145,14 @@ button.approve { border-color: var(--moss); color: var(--moss); } button.approve:hover { background: var(--moss); color: var(--paper); } button.reject { border-color: var(--vermillion); color: var(--vermillion); } button.reject:hover { background: var(--vermillion); color: var(--paper); } +.compile-form { margin-left: auto; } +button.compile { border-color: var(--ink); color: var(--ink); } +button.compile:hover { background: var(--ink); color: var(--paper); } +button.compile:active { opacity: 0.6; } +.notice { font-family: var(--mono); font-size: 0.85rem; padding: 0.4rem 0.6rem; + border: 1px solid var(--rule); margin: 0.5rem 0; } +.notice-ok { border-left: 3px solid var(--moss); } +.notice-err { border-left: 3px solid var(--vermillion); color: var(--vermillion); } input[type="text"] { font-family: var(--sans); diff --git a/src/vouch/web/templates/queue.html b/src/vouch/web/templates/queue.html index 5fb54675..d31b493b 100644 --- a/src/vouch/web/templates/queue.html +++ b/src/vouch/web/templates/queue.html @@ -10,8 +10,21 @@

pending

{% if pages > 1 %} page {{ page }} / {{ pages }} {% endif %} + {% if compile_enabled %} +
+ +
+ {% endif %} + {% if compiled is not none %} +

compiled {{ compiled }} page draft(s) into the queue{% if compile_dropped %} · {{ compile_dropped }} dropped by citation checks{% endif %}.

+ {% endif %} + {% if compile_error %} +

compile failed: {{ compile_error }}

+ {% endif %} + {% if not items %}

no pending proposals — every agent write has been reviewed.

{% else %} diff --git a/tests/test_compile.py b/tests/test_compile.py new file mode 100644 index 00000000..e4fc80ee --- /dev/null +++ b/tests/test_compile.py @@ -0,0 +1,412 @@ +"""Tests for `vouch compile` — the llm-wiki ingest pass. + +The LLM is always a stub here (`cat `), so the tests pin the +mechanical guarantees: drafts become PENDING proposals and never durable +pages, every citation is verified against the store, and one bad draft is +dropped with a reason instead of sinking the batch. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from vouch import compile as compile_mod +from vouch.compile import CompileConfig, CompileError, compile_kb +from vouch.models import ProposalStatus +from vouch.proposals import approve, propose_claim +from vouch.storage import 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 _stub_llm(tmp_path: Path, drafts: list[dict]) -> str: + """Return an llm_cmd that ignores its stdin and emits canned drafts.""" + out = tmp_path / "drafts.json" + out.write_text(json.dumps(drafts), encoding="utf-8") + return f"cat {out}" + + +def _cfg(llm_cmd: str, **kwargs) -> CompileConfig: + return CompileConfig(llm_cmd=llm_cmd, **kwargs) + + +# --- the gate stays intact -------------------------------------------------- + + +def test_compile_files_pending_page_proposals_never_durable_pages( + store: KBStore, tmp_path: Path, +) -> None: + c1 = _approved_claim(store, "the retry limit is three") + c2 = _approved_claim(store, "staging runs postgres sixteen") + cmd = _stub_llm(tmp_path, [ + { + "title": "Billing Retries", + "type": "decision", + "body": f"Retries cap at three [claim: {c1}]. See [[Staging Database]].", + "claims": [c1], + }, + { + "title": "Staging Database", + "type": "workflow", + "body": f"Staging is on postgres 16 [claim: {c2}].", + "claims": [c2], + }, + ]) + + report = compile_kb(store, config=_cfg(cmd)) + + assert len(report.proposed) == 2 + assert report.dropped == [] + # nothing durable yet — compile only proposes. + assert store.list_pages() == [] + pending = store.list_proposals(ProposalStatus.PENDING) + assert {p.kind.value for p in pending} == {"page"} + assert all(p.proposed_by == compile_mod.COMPILE_ACTOR for p in pending) + + # a human approval (different actor) materialises the page. + page = approve(store, report.proposed[0]["proposal_id"], approved_by="human-B") + assert store.get_page(page.id).title == "Billing Retries" + + +def test_dry_run_files_nothing(store: KBStore, tmp_path: Path) -> None: + c1 = _approved_claim(store, "a fact") + cmd = _stub_llm(tmp_path, [ + {"title": "T", "type": "concept", "body": f"x [claim: {c1}]", "claims": [c1]}, + ]) + report = compile_kb(store, config=_cfg(cmd), dry_run=True) + assert report.dry_run + assert len(report.proposed) == 1 + assert store.list_proposals(ProposalStatus.PENDING) == [] + + +# --- citation verification --------------------------------------------------- + + +def test_unknown_claim_id_drops_draft(store: KBStore, tmp_path: Path) -> None: + c1 = _approved_claim(store, "a real fact") + cmd = _stub_llm(tmp_path, [ + {"title": "Ghost", "type": "concept", "body": "x", "claims": ["no-such-claim"]}, + {"title": "Real", "type": "concept", "body": f"y [claim: {c1}]", "claims": [c1]}, + ]) + report = compile_kb(store, config=_cfg(cmd)) + assert [r["title"] for r in report.proposed] == ["Real"] + assert report.dropped[0]["title"] == "Ghost" + assert "unknown claim" in report.dropped[0]["reason"] + + +def test_inline_marker_citing_unlisted_claim_drops_draft( + store: KBStore, tmp_path: Path, +) -> None: + c1 = _approved_claim(store, "listed fact") + c2 = _approved_claim(store, "unlisted fact") + cmd = _stub_llm(tmp_path, [ + { + "title": "Sneaky", + "type": "concept", + "body": f"a [claim: {c1}] and b [claim: {c2}]", + "claims": [c1], # c2 cited inline but not linked + }, + ]) + report = compile_kb(store, config=_cfg(cmd)) + assert report.proposed == [] + assert "unlisted claim" in report.dropped[0]["reason"] + + +def test_uncited_draft_drops(store: KBStore, tmp_path: Path) -> None: + _approved_claim(store, "a fact so the KB is non-empty") + cmd = _stub_llm(tmp_path, [ + {"title": "Vibes", "type": "concept", "body": "trust me", "claims": []}, + ]) + report = compile_kb(store, config=_cfg(cmd)) + assert report.proposed == [] + assert "cites no claims" in report.dropped[0]["reason"] + + +def test_unresolved_wikilink_drops_draft(store: KBStore, tmp_path: Path) -> None: + c1 = _approved_claim(store, "a fact") + cmd = _stub_llm(tmp_path, [ + { + "title": "Linky", + "type": "concept", + "body": f"see [[No Such Page]] [claim: {c1}]", + "claims": [c1], + }, + ]) + report = compile_kb(store, config=_cfg(cmd)) + assert report.proposed == [] + assert "unresolved wikilink" in report.dropped[0]["reason"] + + +def test_wikilink_to_existing_page_resolves(store: KBStore, tmp_path: Path) -> None: + c1 = _approved_claim(store, "a fact") + pr = compile_kb(store, config=_cfg(_stub_llm(tmp_path, [ + {"title": "Anchor", "type": "concept", "body": f"x [claim: {c1}]", + "claims": [c1]}, + ]))) + approve(store, pr.proposed[0]["proposal_id"], approved_by="human-B") + + report = compile_kb(store, config=_cfg(_stub_llm(tmp_path, [ + {"title": "Follower", "type": "concept", + "body": f"see [[Anchor]] [claim: {c1}]", "claims": [c1]}, + ]))) + assert [r["title"] for r in report.proposed] == ["Follower"] + + +def test_session_and_log_types_are_rejected(store: KBStore, tmp_path: Path) -> None: + c1 = _approved_claim(store, "a fact") + cmd = _stub_llm(tmp_path, [ + {"title": "S", "type": "session", "body": f"x [claim: {c1}]", "claims": [c1]}, + {"title": "L", "type": "log", "body": f"x [claim: {c1}]", "claims": [c1]}, + ]) + report = compile_kb(store, config=_cfg(cmd)) + assert report.proposed == [] + assert len(report.dropped) == 2 + + +def test_max_pages_cap(store: KBStore, tmp_path: Path) -> None: + c1 = _approved_claim(store, "a fact") + drafts = [ + {"title": f"P{i}", "type": "concept", "body": f"x [claim: {c1}]", + "claims": [c1]} + for i in range(4) + ] + report = compile_kb(store, config=_cfg(_stub_llm(tmp_path, drafts)), max_pages=2) + assert len(report.proposed) == 2 + assert all("max_pages" in d["reason"] for d in report.dropped) + + +# --- failure shapes ----------------------------------------------------------- + + +def test_missing_llm_cmd_raises(store: KBStore) -> None: + _approved_claim(store, "a fact") + with pytest.raises(CompileError, match="llm_cmd is not configured"): + compile_kb(store, config=CompileConfig()) + + +def test_empty_kb_raises_before_running_llm(store: KBStore) -> None: + with pytest.raises(CompileError, match="nothing to compile"): + compile_kb(store, config=_cfg("false")) + + +def test_llm_failure_raises(store: KBStore) -> None: + _approved_claim(store, "a fact") + with pytest.raises(CompileError, match="failed"): + compile_kb(store, config=_cfg("false")) + + +def test_non_json_output_raises(store: KBStore) -> None: + _approved_claim(store, "a fact") + with pytest.raises(CompileError, match="not valid JSON"): + compile_kb(store, config=_cfg("echo hello world")) + + +def test_fenced_json_is_tolerated(store: KBStore, tmp_path: Path) -> None: + c1 = _approved_claim(store, "a fact") + out = tmp_path / "fenced.txt" + body = json.dumps([ + {"title": "F", "type": "concept", "body": f"x [claim: {c1}]", "claims": [c1]}, + ]) + out.write_text(f"```json\n{body}\n```", encoding="utf-8") + report = compile_kb(store, config=_cfg(f"cat {out}")) + assert [r["title"] for r in report.proposed] == ["F"] + + +# --- config ------------------------------------------------------------------- + + +def test_load_config_reads_compile_stanza(store: KBStore) -> None: + store.config_path.write_text( + store.config_path.read_text(encoding="utf-8") + + "\ncompile:\n llm_cmd: \"cat /dev/null\"\n max_pages: 9\n", + encoding="utf-8", + ) + cfg = compile_mod.load_config(store) + assert cfg.llm_cmd == "cat /dev/null" + assert cfg.max_pages == 9 + + +def test_load_config_defaults_when_absent(store: KBStore) -> None: + cfg = compile_mod.load_config(store) + assert cfg.llm_cmd is None + assert cfg.max_pages == compile_mod.DEFAULT_MAX_PAGES + + +def test_load_config_bad_values_fall_back_to_defaults(store: KBStore) -> None: + # a config typo must degrade, not 500 the web queue that reads this + # config on every render. + store.config_path.write_text( + store.config_path.read_text(encoding="utf-8") + + "\ncompile:\n llm_cmd: \"cat /dev/null\"\n" + " max_pages: five\n timeout_seconds:\n", + encoding="utf-8", + ) + cfg = compile_mod.load_config(store) + assert cfg.llm_cmd == "cat /dev/null" + assert cfg.max_pages == compile_mod.DEFAULT_MAX_PAGES + assert cfg.timeout_seconds == compile_mod.DEFAULT_TIMEOUT_SECONDS + + +# --- review-hardening regressions -------------------------------------------- + + +def test_non_dict_array_elements_raise(store: KBStore, tmp_path: Path) -> None: + _approved_claim(store, "a fact") + out = tmp_path / "strings.json" + out.write_text(json.dumps(["Page One", "Page Two"]), encoding="utf-8") + with pytest.raises(CompileError, match="array of page objects"): + compile_kb(store, config=_cfg(f"cat {out}")) + + +def test_collision_with_existing_page_dropped( + store: KBStore, tmp_path: Path, +) -> None: + """approve() routes an existing page id through update_page, so a + colliding draft would silently overwrite the page — compile must drop + it at validation time.""" + c1 = _approved_claim(store, "a fact") + first = compile_kb(store, config=_cfg(_stub_llm(tmp_path, [ + {"title": "Deploy Workflow", "type": "workflow", + "body": f"x [claim: {c1}]", "claims": [c1]}, + ]))) + approve(store, first.proposed[0]["proposal_id"], approved_by="human-B") + before = store.get_page("deploy-workflow").body + + report = compile_kb(store, config=_cfg(_stub_llm(tmp_path, [ + {"title": "Deploy Workflow", "type": "workflow", + "body": f"OVERWRITE [claim: {c1}]", "claims": [c1]}, + ]))) + assert report.proposed == [] + assert "already exists" in report.dropped[0]["reason"] + assert store.get_page("deploy-workflow").body == before + + +def test_collision_with_pending_proposal_dropped( + store: KBStore, tmp_path: Path, +) -> None: + """re-running compile (or double-clicking the button) must not queue + duplicate drafts of the same topic.""" + c1 = _approved_claim(store, "a fact") + drafts = [{"title": "Retry Policy", "type": "decision", + "body": f"x [claim: {c1}]", "claims": [c1]}] + first = compile_kb(store, config=_cfg(_stub_llm(tmp_path, drafts))) + assert len(first.proposed) == 1 + + second = compile_kb(store, config=_cfg(_stub_llm(tmp_path, drafts))) + assert second.proposed == [] + assert "pending review" in second.dropped[0]["reason"] + + +def test_dropped_sibling_dangles_wikilink_and_cascades( + store: KBStore, tmp_path: Path, +) -> None: + """A links [[B]]; B cites an unknown claim. B drops for the citation, + and A must then drop too — filing A would ship a dangling link.""" + c1 = _approved_claim(store, "a fact") + report = compile_kb(store, config=_cfg(_stub_llm(tmp_path, [ + {"title": "A", "type": "concept", + "body": f"see [[B]] [claim: {c1}]", "claims": [c1]}, + {"title": "B", "type": "concept", "body": "x", "claims": ["no-such"]}, + ]))) + assert report.proposed == [] + reasons = {d["title"]: d["reason"] for d in report.dropped} + assert "unknown claim" in reasons["B"] + assert "unresolved wikilink" in reasons["A"] + + +def test_capped_sibling_dangles_wikilink(store: KBStore, tmp_path: Path) -> None: + c1 = _approved_claim(store, "a fact") + report = compile_kb(store, config=_cfg(_stub_llm(tmp_path, [ + {"title": "A", "type": "concept", + "body": f"see [[B]] [claim: {c1}]", "claims": [c1]}, + {"title": "B", "type": "concept", + "body": f"y [claim: {c1}]", "claims": [c1]}, + ])), max_pages=1) + # B falls to the cap; A's [[B]] then dangles, so nothing survives. + assert report.proposed == [] + + +def test_unicode_body_survives(store: KBStore, tmp_path: Path) -> None: + c1 = _approved_claim(store, "a fact") + body = f"em-dash — and naïve café [claim: {c1}]" + report = compile_kb(store, config=_cfg(_stub_llm(tmp_path, [ + {"title": "Unicode", "type": "concept", "body": body, "claims": [c1]}, + ]))) + assert len(report.proposed) == 1 + pending = store.list_proposals(ProposalStatus.PENDING) + assert pending[0].payload["body"] == body + + +def test_jsonl_kb_compile_files_proposals( + store: KBStore, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + """kb.compile over the JSONL wire — what vouch-ui calls — files pending + page proposals and returns the report envelope.""" + from vouch.jsonl_server import handle_request + + c1 = _approved_claim(store, "a wire-visible fact") + cmd = _stub_llm(tmp_path, [ + {"title": "Wire Topic", "type": "concept", + "body": f"x [claim: {c1}]", "claims": [c1]}, + ]) + store.config_path.write_text( + store.config_path.read_text(encoding="utf-8") + + f"\ncompile:\n llm_cmd: \"{cmd}\"\n", + encoding="utf-8", + ) + monkeypatch.chdir(store.root) + resp = handle_request({"id": "r1", "method": "kb.compile", "params": {}}) + assert resp["ok"] + assert resp["result"]["proposed"][0]["title"] == "Wire Topic" + assert store.list_pages() == [] + pending = store.list_proposals(ProposalStatus.PENDING) + assert pending and pending[0].kind.value == "page" + + +def test_jsonl_kb_compile_unconfigured_is_clean_error( + store: KBStore, monkeypatch: pytest.MonkeyPatch, +) -> None: + from vouch.jsonl_server import handle_request + + _approved_claim(store, "a fact") + monkeypatch.chdir(store.root) + resp = handle_request({"id": "r2", "method": "kb.compile", "params": {}}) + assert not resp["ok"] + assert "llm_cmd is not configured" in resp["error"]["message"] + + +def test_compile_logs_attributed_audit_event( + store: KBStore, tmp_path: Path, +) -> None: + from vouch import audit as audit_mod + + c1 = _approved_claim(store, "a fact") + cmd = _stub_llm(tmp_path, [ + {"title": "T", "type": "concept", "body": f"x [claim: {c1}]", + "claims": [c1]}, + ]) + compile_kb(store, config=_cfg(cmd), triggered_by="human-reviewer") + events = [e for e in audit_mod.read_events(store.kb_dir) + if e.event == "compile.run"] + assert len(events) == 1 + assert events[0].actor == "human-reviewer" + assert events[0].data["proposed"] == 1 + + # dry runs mutate nothing and log nothing. + compile_kb(store, config=_cfg(cmd), dry_run=True, triggered_by="human-reviewer") + events = [e for e in audit_mod.read_events(store.kb_dir) + if e.event == "compile.run"] + assert len(events) == 1 diff --git a/tests/test_digest.py b/tests/test_digest.py index 8bb55205..f5cc70d3 100644 --- a/tests/test_digest.py +++ b/tests/test_digest.py @@ -3,13 +3,14 @@ from __future__ import annotations import json -from datetime import UTC, datetime, timedelta +from datetime import UTC, datetime, timedelta, tzinfo from pathlib import Path import pytest from click.testing import CliRunner from vouch import digest as digest_mod +from vouch import proposals as proposals_mod from vouch.cli import cli from vouch.models import ( Claim, @@ -26,8 +27,22 @@ NOW = datetime(2026, 7, 4, 12, 0, 0, tzinfo=UTC) +class _FrozenDatetime(datetime): + """`datetime` whose `now` is pinned to NOW. + + approve/reject stamp `decided_at` with wall-clock time; the window tests + anchor to NOW, so decisions must be stamped relative to NOW too or the + assertions rot as real time passes the fixture dates. + """ + + @classmethod + def now(cls, tz: tzinfo | None = None) -> datetime: + return NOW if tz is not None else NOW.replace(tzinfo=None) + + @pytest.fixture -def store(tmp_path: Path) -> KBStore: +def store(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> KBStore: + monkeypatch.setattr(proposals_mod, "datetime", _FrozenDatetime) s = KBStore.init(tmp_path) src = s.put_source(b"evidence body", title="src", locator="test:src", source_type="message") diff --git a/tests/test_web.py b/tests/test_web.py index e44a7fc8..a92f502c 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -269,3 +269,128 @@ def test_cli_review_ui_refuses_non_localhost_bind_without_auth(tmp_path: Path) - assert result.exit_code != 0 assert "--auth" in result.output assert "non-loopback" in result.output.lower() + + +# --- compile button + route ------------------------------------------------ + + +def _approved_claim(store: KBStore, text: str = "an approved fact") -> str: + from vouch.proposals import approve + + src = store.put_source(text.encode()) + pr = propose_claim(store, text=text, evidence=[src.id], proposed_by="agent-A") + return approve(store, pr.id, approved_by="human-B").id + + +def _configure_compile(store: KBStore, tmp_path: Path, drafts: list | None) -> None: + """Point compile.llm_cmd at a stub that emits canned drafts (or fails).""" + import json as json_mod + + if drafts is None: + cmd = "false" + else: + out = tmp_path / "web-drafts.json" + out.write_text(json_mod.dumps(drafts), encoding="utf-8") + cmd = f"cat {out}" + store.config_path.write_text( + store.config_path.read_text(encoding="utf-8") + + f"\ncompile:\n llm_cmd: \"{cmd}\"\n", + encoding="utf-8", + ) + + +def test_compile_button_hidden_without_config(client: TestClient) -> None: + r = client.get("/") + assert r.status_code == 200 + assert "compile wiki" not in r.text + + +def test_compile_button_shown_when_configured( + client: TestClient, store: KBStore, tmp_path: Path, +) -> None: + _configure_compile(store, tmp_path, []) + r = client.get("/") + assert r.status_code == 200 + assert "compile wiki" in r.text + + +def test_compile_post_unconfigured_is_400(client: TestClient) -> None: + r = client.post("/compile", follow_redirects=False) + assert r.status_code == 400 + + +def test_compile_post_lands_drafts_in_queue( + client: TestClient, store: KBStore, tmp_path: Path, +) -> None: + """The button runs the ingest pass and its drafts appear as pending + page proposals in the same queue — proposed, never approved.""" + cid = _approved_claim(store) + _configure_compile(store, tmp_path, [ + {"title": "Compiled Topic", "type": "concept", + "body": f"x [claim: {cid}]", "claims": [cid]}, + ]) + r = client.post("/compile", follow_redirects=False) + assert r.status_code == 303 + assert "compiled=1" in r.headers["location"] + + pending = store.list_proposals(ProposalStatus.PENDING) + assert any( + p.kind.value == "page" and p.payload.get("title") == "Compiled Topic" + for p in pending + ) + assert store.list_pages() == [] # still nothing durable + + follow = client.get(r.headers["location"]) + assert "compiled 1 page draft(s)" in follow.text + + +def test_compile_post_failure_redirects_with_error( + client: TestClient, store: KBStore, tmp_path: Path, +) -> None: + _approved_claim(store) + _configure_compile(store, tmp_path, None) # llm_cmd = false + r = client.post("/compile", follow_redirects=False) + assert r.status_code == 303 + assert "compile_error=" in r.headers["location"] + follow = client.get(r.headers["location"]) + assert "compile failed" in follow.text + + +def test_compile_post_while_running_redirects_busy( + client: TestClient, store: KBStore, tmp_path: Path, +) -> None: + """One compile at a time per KB — a double-click must not stack LLM + runs on threadpool threads.""" + import asyncio + + _approved_claim(store) + _configure_compile(store, tmp_path, []) + lock = client.app.state.compile_lock + asyncio.run(lock.acquire()) + try: + r = client.post("/compile", follow_redirects=False) + assert r.status_code == 303 + assert "compile_error=" in r.headers["location"] + assert "already%20running" in r.headers["location"] + finally: + lock.release() + + +def test_compile_run_attributed_in_audit_log( + client: TestClient, store: KBStore, tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A web-triggered compile must record who pressed the button, not just + the wiki-compiler proposer identity.""" + monkeypatch.setenv("VOUCH_AGENT", "human-reviewer") + cid = _approved_claim(store) + _configure_compile(store, tmp_path, [ + {"title": "Attributed", "type": "concept", + "body": f"x [claim: {cid}]", "claims": [cid]}, + ]) + r = client.post("/compile", follow_redirects=False) + assert r.status_code == 303 + events = [e for e in audit_mod.read_events(store.kb_dir) + if e.event == "compile.run"] + assert len(events) == 1 + assert events[0].actor == "human-reviewer"