From 898f3e5b95663505760769e27952c32efc434c77 Mon Sep 17 00:00:00 2001 From: andyk3247 Date: Fri, 10 Jul 2026 16:34:43 +0200 Subject: [PATCH] feat(capture): auto-propose a draft claim on user correction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit detect a user correction ("no, we deploy from main not release") on the UserPromptSubmit turn with a cheap no-llm heuristic and file it as a PENDING claim proposal quoting the correction, tagged auto:correction and citing the correction message as evidence. proposes, never approves — there is no code path to proposals.approve, so the pending queue is the draft state a human still drains. near- duplicate corrections are suppressed via the #147 similarity path plus a cheap exact-repeat check, and capture.correction.per_session_cap (default 3) bounds an over-eager heuristic. driven by a new `vouch capture correction` subcommand wired into the claude-code UserPromptSubmit hook; config capture.correction.{enabled, per_session_cap} with defaults in the starter config. tests in tests/test_capture_correction.py assert the propose-only invariant, the cap, dedup, and config gating. closes #430 --- CHANGELOG.md | 13 ++ adapters/claude-code/.claude/settings.json | 5 + src/vouch/cli.py | 38 ++++ src/vouch/correction.py | 207 +++++++++++++++++++++ src/vouch/storage.py | 7 + tests/test_capture_correction.py | 198 ++++++++++++++++++++ 6 files changed, 468 insertions(+) create mode 100644 src/vouch/correction.py create mode 100644 tests/test_capture_correction.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 986dcb0e..0d1558a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,19 @@ All notable changes to vouch are documented here. Format follows ## [Unreleased] +### Added +- correction-capture: a detected user correction ("no, we deploy from `main` + not `release`") becomes a PENDING claim proposal for human review. a + `UserPromptSubmit` hook (`vouch capture correction`) runs a cheap, no-llm + heuristic on the turn — a pushback opener or corrective phrase — and, when it + fires, files one draft claim quoting the correction, tagged `auto:correction` + and citing the correction message as evidence. it **proposes, never approves** + (no code path to `proposals.approve`): the pending queue is the draft state. + near-duplicate corrections are suppressed via the #147 similarity path (plus a + cheap exact-repeat check), and `capture.correction.per_session_cap` (default 3) + bounds an over-eager heuristic. opt out with `capture.correction.enabled: + false` in `.vouch/config.yaml`. + ## [1.2.2] — 2026-07-07 ### Packaging diff --git a/adapters/claude-code/.claude/settings.json b/adapters/claude-code/.claude/settings.json index d77ad5ad..f86d5ed1 100644 --- a/adapters/claude-code/.claude/settings.json +++ b/adapters/claude-code/.claude/settings.json @@ -51,6 +51,11 @@ { "type": "command", "command": "vouch context-hook || true" + }, + { + "comment": "detect a user correction and file it as a PENDING claim proposal (review-gated; never approves); never blocks the turn", + "type": "command", + "command": "vouch capture correction || true" } ] } diff --git a/src/vouch/cli.py b/src/vouch/cli.py index c6b04ccf..9be5924a 100644 --- a/src/vouch/cli.py +++ b/src/vouch/cli.py @@ -27,6 +27,7 @@ from . import capture as capture_mod from . import codex_rollout as codex_rollout_mod from . import compile as compile_mod +from . import correction as correction_mod from . import digest as digest_mod from . import fetch as fetch_mod from . import inbox as inbox_mod @@ -2016,6 +2017,43 @@ def capture_observe_cmd() -> None: return +@capture.command("correction") +@click.option("--prompt", default=None, help="Correction text (else read from stdin payload).") +@click.option("--session-id", default=None, help="Session id (else from stdin payload).") +@click.option("--json", "as_json", is_flag=True, help="Print the capture result as JSON.") +def capture_correction_cmd( + prompt: str | None, session_id: str | None, as_json: bool +) -> None: + """Detect a user correction and file it as a PENDING claim proposal. + + Driven by the UserPromptSubmit hook (payload JSON on stdin, supplying + `prompt` + `session_id`). Proposes, never approves; prints nothing and + exits 0 on any error so it can never block the user's turn. + """ + try: + if prompt is None and not sys.stdin.isatty(): + raw = sys.stdin.read() + payload = json.loads(raw) if raw.strip() else {} + if isinstance(payload, dict): + prompt = str(payload.get("prompt") or "") + if session_id is None: + sid = payload.get("session_id") + session_id = str(sid) if sid else None + if not prompt: + return + store = _capture_store() + if store is None: + return + result = correction_mod.capture_correction( + store, prompt=prompt, session_id=session_id, + ) + if as_json: + _emit_json(result) + except Exception: + # correction-capture must never break the user's turn. + return + + @capture.command("finalize") @click.option("--session-id", default=None, help="Session id (else read from stdin payload).") def capture_finalize_cmd(session_id: str | None) -> None: diff --git a/src/vouch/correction.py b/src/vouch/correction.py new file mode 100644 index 00000000..824727a7 --- /dev/null +++ b/src/vouch/correction.py @@ -0,0 +1,207 @@ +"""Correction-capture — turn a user pushback into a *pending* draft claim. + +The single highest-signal event in a session is the user correcting the agent +("no, we deploy from `main` not `release`"). Passive tool-capture +(`capture.observe`) never sees it, and it evaporates unless someone proposes a +claim by hand. This module detects that turn cheaply (no LLM) and, when it +fires, files ONE pending claim proposal quoting the correction. + +The whole design constraint: it **proposes, never writes**. There is no code +path from here to `proposals.approve` — the correction lands in the pending +queue as a normal draft tagged `auto:correction`, and a human still drains it. +Because it routes exclusively through `proposals.propose_claim`, it cannot +create approved knowledge and cannot bypass the review gate. + +Guards against swamping the reviewer: + - a per-session cap on auto-proposals (`capture.correction.per_session_cap`); + - dedup against near-duplicate approved/pending claims via the #147 + similarity path, plus a cheap embeddings-free check for exact repeats. + +Config (read defensively from ``.vouch/config.yaml``): + - ``capture.correction.enabled`` (default True) + - ``capture.correction.per_session_cap`` (default 3) +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +import yaml + +from .models import ProposalKind +from .proposals import propose_claim +from .storage import KBStore + +CORRECTION_ACTOR = "auto:correction" +CORRECTION_TAG = "auto:correction" +DEFAULT_ENABLED = True +DEFAULT_PER_SESSION_CAP = 3 +_MAX_CLAIM_CHARS = 500 + +# Leading pushback markers — a negation/"actually" at the very start of the +# turn is the strongest cheap signal that the user is correcting the agent. +# Punctuation is required on the bare "no" forms so an innocent "no idea why…" +# doesn't trip the heuristic. +_OPENERS = ( + "no,", "no.", "no!", "nope", "not quite", + "actually,", "actually ", "wrong,", "incorrect,", + "that's not", "that is not", "that's wrong", "that is wrong", + "that's incorrect", "that is incorrect", + "you're wrong", "you are wrong", "correction:", +) +# Strong corrective phrases that count anywhere in a short turn. +_PHRASES = ( + "we don't ", "we do not ", "it's not ", "it is not ", + "that's not right", "not from ", +) +# Prefixes stripped off the front of the captured correction so the claim +# quotes the corrective content, not the pushback marker. +_STRIP_PREFIXES = ( + "no,", "no.", "no!", "no ", "nope,", "nope.", "nope ", + "actually,", "actually ", "wrong,", "incorrect,", "correction:", + "that's wrong,", "that is wrong,", "that's incorrect,", +) + + +@dataclass(frozen=True) +class CorrectionConfig: + """Resolved ``capture.correction`` config.""" + + enabled: bool = DEFAULT_ENABLED + per_session_cap: int = DEFAULT_PER_SESSION_CAP + + +def load_config(store: KBStore) -> CorrectionConfig: + """Read ``capture.correction`` from config.yaml; fall back to defaults.""" + try: + loaded = yaml.safe_load(store.config_path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, yaml.YAMLError): + return CorrectionConfig() + if not isinstance(loaded, dict): + return CorrectionConfig() + capture = loaded.get("capture") + raw = capture.get("correction") if isinstance(capture, dict) else None + if not isinstance(raw, dict): + return CorrectionConfig() + cap = raw.get("per_session_cap", DEFAULT_PER_SESSION_CAP) + cap = cap if isinstance(cap, int) and cap >= 0 else DEFAULT_PER_SESSION_CAP + return CorrectionConfig( + enabled=bool(raw.get("enabled", DEFAULT_ENABLED)), + per_session_cap=cap, + ) + + +def _strip_prefix(text: str) -> str: + low = text.lower() + for prefix in _STRIP_PREFIXES: + if low.startswith(prefix): + return text[len(prefix):].strip() + return text + + +def detect_correction(prompt: str) -> str | None: + """Return the corrective content if ``prompt`` reads like a user correction. + + Cheap heuristic (no LLM): a pushback marker leading the turn, or a strong + corrective phrase anywhere in it. Returns the corrective text with the + leading marker stripped, or ``None`` when no correction is detected. Tuned + to accept false positives — every hit is only a *proposal* a human reviews, + and the per-session cap bounds an over-eager trigger. + """ + text = (prompt or "").strip() + if not text: + return None + low = text.lower() + matched = low.startswith(_OPENERS) or any(p in low for p in _PHRASES) + if not matched: + return None + corrective = _strip_prefix(text).strip() or text + return corrective[:_MAX_CLAIM_CHARS] + + +def _norm(text: str) -> str: + return " ".join(text.split()).casefold() + + +def _session_correction_count(store: KBStore, session_id: str | None) -> int: + """How many auto:correction proposals this session has already filed.""" + return sum( + 1 for p in store.list_proposals() + if p.proposed_by == CORRECTION_ACTOR and p.session_id == session_id + ) + + +def _is_duplicate(store: KBStore, corrective: str) -> bool: + """True if ``corrective`` duplicates an approved/pending claim. + + Uses the #147 similarity path (semantic, when the embeddings extra is + installed) and a cheap embeddings-free check for an exact repeat already + sitting in the pending queue. + """ + try: + from .embeddings.similarity import find_similar_on_propose + if find_similar_on_propose(store, corrective): + return True + except ImportError: + pass + target = _norm(corrective) + for p in store.list_proposals(): + if p.kind != ProposalKind.CLAIM: + continue + if _norm(str(p.payload.get("text", ""))) == target: + return True + return False + + +def capture_correction( + store: KBStore, + *, + prompt: str, + session_id: str | None = None, + config: CorrectionConfig | None = None, +) -> dict[str, Any]: + """Detect a correction in ``prompt`` and enqueue one PENDING claim. No approve(). + + Returns a result dict describing what happened: ``captured`` plus either a + ``proposal_id`` / ``source_id`` or a ``skipped`` reason + (``disabled`` / ``no-correction`` / ``session-cap`` / ``duplicate``). + """ + cfg = config or load_config(store) + if not cfg.enabled: + return {"captured": False, "skipped": "disabled"} + + corrective = detect_correction(prompt) + if corrective is None: + return {"captured": False, "skipped": "no-correction"} + + if _session_correction_count(store, session_id) >= cfg.per_session_cap: + return {"captured": False, "skipped": "session-cap", "correction": corrective} + + if _is_duplicate(store, corrective): + return {"captured": False, "skipped": "duplicate", "correction": corrective} + + # The correction message itself is the evidence — register it as a + # content-addressed message source so the claim cites something concrete + # (and identical corrections collapse to the same source id). + source = store.put_source( + corrective.encode("utf-8"), + title=f"user correction ({session_id or 'session'})", + source_type="message", + metadata={"origin": CORRECTION_ACTOR, "session_id": session_id}, + ) + result = propose_claim( + store, + text=corrective, + evidence=[source.id], + proposed_by=CORRECTION_ACTOR, + tags=[CORRECTION_TAG], + rationale="captured from user correction", + session_id=session_id, + ) + return { + "captured": True, + "proposal_id": result.proposal.id, + "source_id": source.id, + "correction": corrective, + } diff --git a/src/vouch/storage.py b/src/vouch/storage.py index 3a057db3..a72f076f 100644 --- a/src/vouch/storage.py +++ b/src/vouch/storage.py @@ -84,6 +84,13 @@ def _starter_config() -> dict[str, Any]: # auto-capture claude code sessions into pending summaries. "enabled": True, "min_observations": 3, + # correction-capture: a detected user correction ("no, we deploy + # from main not release") becomes a PENDING claim proposal. + # review-gated — never auto-approved; per_session_cap bounds it. + "correction": { + "enabled": True, + "per_session_cap": 3, + }, }, "recall": { # inject a digest of all approved knowledge at session start. diff --git a/tests/test_capture_correction.py b/tests/test_capture_correction.py new file mode 100644 index 00000000..282aa2f7 --- /dev/null +++ b/tests/test_capture_correction.py @@ -0,0 +1,198 @@ +"""Correction-capture — propose-only invariant, per-session cap, dedup, gating. + +The load-bearing test is `test_capture_is_propose_only`: a detected correction +must land as a PENDING claim proposal and nothing may become approved. The +module has no call path to `proposals.approve`. +""" + +from __future__ import annotations + +import ast +import inspect +from pathlib import Path + +import pytest + +from vouch import correction +from vouch.models import ProposalKind, ProposalStatus +from vouch.storage import KBStore + + +@pytest.fixture +def store(tmp_path: Path) -> KBStore: + """A fresh KB rooted at a temp dir.""" + return KBStore.init(tmp_path) + + +# --- detection heuristic -------------------------------------------------- + + +@pytest.mark.parametrize("prompt", [ + "no, we deploy from main not release", + "No. that's the staging bucket, prod is us-west-2", + "actually, the retry limit is 5 not 3", + "that's wrong — the service owner is the platform team", + "we don't use redis for sessions anymore", + "nope, the endpoint is /v2/users", +]) +def test_detect_correction_positive(prompt: str) -> None: + """Pushback openers and corrective phrases are detected.""" + assert correction.detect_correction(prompt) is not None + + +@pytest.mark.parametrize("prompt", [ + "please add a test for the parser", + "can you deploy from main?", + "no idea why the build is red, can you look?", + "", + " ", +]) +def test_detect_correction_negative(prompt: str) -> None: + """Ordinary instructions and questions are not treated as corrections.""" + assert correction.detect_correction(prompt) is None + + +def test_detect_strips_leading_marker() -> None: + """The captured text quotes the correction, not the pushback marker.""" + assert correction.detect_correction("no, we deploy from main") == "we deploy from main" + assert correction.detect_correction("actually, prod is us-west-2") == "prod is us-west-2" + + +# --- propose-only invariant ----------------------------------------------- + + +def test_capture_is_propose_only(store: KBStore) -> None: + """A correction enqueues a PENDING claim; nothing is approved.""" + result = correction.capture_correction( + store, prompt="no, we deploy from main not release", session_id="s1", + ) + assert result["captured"] is True + + pending = store.list_proposals(status=ProposalStatus.PENDING) + assert len(pending) == 1 + prop = pending[0] + assert prop.kind == ProposalKind.CLAIM + assert prop.proposed_by == correction.CORRECTION_ACTOR + assert prop.status == ProposalStatus.PENDING + assert prop.rationale == "captured from user correction" + assert correction.CORRECTION_TAG in prop.payload.get("tags", []) + assert prop.payload["text"] == "we deploy from main not release" + + # The claim cites the registered correction message as evidence. + assert prop.payload["evidence"] == [result["source_id"]] + assert store.get_source(result["source_id"]).type == "message" + + # Nothing crossed the review gate. + assert store.list_claims() == [] + assert store.list_proposals(status=ProposalStatus.APPROVED) == [] + + +def test_module_has_no_approve_path() -> None: + """Structural guard: the module never imports or calls proposals.approve.""" + tree = ast.parse(inspect.getsource(correction)) + imported = { + alias.name + for node in ast.walk(tree) if isinstance(node, ast.ImportFrom) + for alias in node.names + } + called = { + node.func.attr if isinstance(node.func, ast.Attribute) + else getattr(node.func, "id", None) + for node in ast.walk(tree) if isinstance(node, ast.Call) + } + assert "approve" not in imported + assert "approve" not in called + + +# --- per-session cap ------------------------------------------------------ + + +def test_per_session_cap_bounds_auto_proposals(store: KBStore) -> None: + """Distinct corrections stop being filed once the session hits the cap.""" + cfg = correction.CorrectionConfig(enabled=True, per_session_cap=2) + for i in range(5): + correction.capture_correction( + store, prompt=f"no, fact number {i} is the real value", + session_id="s1", config=cfg, + ) + pending = store.list_proposals(status=ProposalStatus.PENDING) + assert len(pending) == 2 + + # The overflow is reported as a cap skip, not an error. + over = correction.capture_correction( + store, prompt="no, one more distinct correction here", + session_id="s1", config=cfg, + ) + assert over["captured"] is False + assert over["skipped"] == "session-cap" + + +def test_cap_is_per_session(store: KBStore) -> None: + """A different session gets its own budget.""" + cfg = correction.CorrectionConfig(enabled=True, per_session_cap=1) + correction.capture_correction( + store, prompt="no, alpha is the value", session_id="s1", config=cfg) + blocked = correction.capture_correction( + store, prompt="no, beta is the value", session_id="s1", config=cfg) + assert blocked["skipped"] == "session-cap" + + other = correction.capture_correction( + store, prompt="no, gamma is the value", session_id="s2", config=cfg) + assert other["captured"] is True + + +# --- dedup ---------------------------------------------------------------- + + +def test_duplicate_correction_suppressed(store: KBStore) -> None: + """An exact-repeat correction is suppressed (embeddings-free path).""" + first = correction.capture_correction( + store, prompt="no, we deploy from main", session_id="s1") + assert first["captured"] is True + + second = correction.capture_correction( + store, prompt="no, we deploy from main", session_id="s1") + assert second["captured"] is False + assert second["skipped"] == "duplicate" + + assert len(store.list_proposals(status=ProposalStatus.PENDING)) == 1 + + +# --- config gate ---------------------------------------------------------- + + +def test_disabled_via_config(store: KBStore) -> None: + """capture.correction.enabled: false makes capture a no-op.""" + store.config_path.write_text( + "capture:\n correction:\n enabled: false\n", encoding="utf-8") + result = correction.capture_correction( + store, prompt="no, this is a correction", session_id="s1") + assert result["captured"] is False + assert result["skipped"] == "disabled" + assert store.list_proposals(status=ProposalStatus.PENDING) == [] + + +def test_load_config_defaults(store: KBStore) -> None: + """Absent config yields enabled=True with the default cap.""" + cfg = correction.load_config(store) + assert cfg.enabled is True + assert cfg.per_session_cap == correction.DEFAULT_PER_SESSION_CAP + + +def test_load_config_reads_override(store: KBStore) -> None: + """The capture.correction block overrides the defaults.""" + store.config_path.write_text( + "capture:\n correction:\n enabled: false\n per_session_cap: 7\n", + encoding="utf-8") + cfg = correction.load_config(store) + assert cfg.enabled is False + assert cfg.per_session_cap == 7 + + +def test_non_correction_prompt_files_nothing(store: KBStore) -> None: + """A normal instruction produces no proposal.""" + result = correction.capture_correction( + store, prompt="please refactor the parser", session_id="s1") + assert result["captured"] is False + assert result["skipped"] == "no-correction" + assert store.list_proposals() == []