From 06b2f282485c15c6ac9e300350aa931280cf0cfc Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Fri, 17 Jul 2026 18:12:23 +0900 Subject: [PATCH] fix(extract): stop fragment claims and stopword-matched injection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit three fixes that close the gap between "receipt verifies" and "claim worth storing": segment_source now treats punctuation as a sentence boundary only when followed by whitespace, so periods inside version numbers, file paths, and markdown links no longer shear spans into shards like "0`, and let release." a claim-worthiness gate drops spans with dangling backticks, unbalanced brackets, punctuation-led starts, or fewer than three words, so the receipt auto-approve path can no longer launder mangled markup into approved claims. the prompt hook searches only on informative tokens (stopwords stripped) and injects nothing when a prompt has none, and context packs carry the full approved claim text instead of the fts5 «»-highlighted 16-token snippet window, which read as garbage in model context. --- src/vouch/context.py | 5 ++++ src/vouch/extract.py | 57 ++++++++++++++++++++++++++++++++-------- src/vouch/hooks.py | 57 +++++++++++++++++++++++++++++++++++++++- tests/test_extract.py | 44 +++++++++++++++++++++++++++++++ tests/test_hooks.py | 60 ++++++++++++++++++++++++++++++++++++++++++- 5 files changed, 210 insertions(+), 13 deletions(-) diff --git a/src/vouch/context.py b/src/vouch/context.py index b4790251..09000dfe 100644 --- a/src/vouch/context.py +++ b/src/vouch/context.py @@ -542,6 +542,11 @@ def build_context_pack( if claim.status in _RETRACTED_CLAIM_STATUSES: continue cites = list(claim.evidence) + # The FTS5 snippet is a «»-highlighted 16-token window — a + # search-UI affordance. Consumers of the pack (hook injection, + # kb_context) need the whole approved proposition; claims are + # length-capped, and the max_chars budget below still applies. + summary = claim.text summary = _enrich_summary(store, kind, hid, summary) items.append( ContextItem( diff --git a/src/vouch/extract.py b/src/vouch/extract.py index 80e115e3..b9959212 100644 --- a/src/vouch/extract.py +++ b/src/vouch/extract.py @@ -25,10 +25,11 @@ ) from .storage import KBStore -# Split on newlines and sentence-ending punctuation. Each match is kept as an -# exact substring of the source so its receipt verifies; only surrounding -# whitespace is stripped (the inner run stays contiguous in the source). -_SEGMENT_RE = re.compile(r"[^\n.!?]+[.!?]?") +# A sentence boundary is punctuation followed by whitespace (or a newline). +# A bare dot is NOT a boundary: periods inside "1.2.0", "cli.py:2550", or a +# markdown link have no trailing whitespace, and splitting on them minted +# mid-token shards like "0`, and let release." as claims. +_BOUNDARY_RE = re.compile(r"[.!?]+(?=\s)|\n") DEFAULT_MIN_CHARS = 16 DEFAULT_MAX_CHARS = 320 @@ -37,12 +38,45 @@ # the characters to be letters. _MIN_LETTER_RATIO = 0.5 +_OPENING_CHARS = "\"'`([{" +_BRACKET_PAIRS = (("(", ")"), ("[", "]"), ("{", "}")) + + +def _split_spans(text: str) -> list[str]: + """Slice text at sentence boundaries; every span is a verbatim substring.""" + spans: list[str] = [] + start = 0 + for match in _BOUNDARY_RE.finditer(text): + spans.append(text[start:match.end()]) + start = match.end() + if start < len(text): + spans.append(text[start:]) + return spans + def _is_noise(segment: str) -> bool: letters = sum(c.isalpha() for c in segment) return letters < len(segment) * _MIN_LETTER_RATIO +def _is_claimworthy(segment: str) -> bool: + """A span must read as a proposition, not a shard of mangled markup. + + Dangling backticks/brackets and punctuation-led starts are the signature + of a segment cut mid-construct; single- or two-word spans carry no + proposition. This gate is what keeps "receipt verifies" from being the + only bar a claim has to clear. + """ + first = segment[0] + if not (first.isalnum() or first in _OPENING_CHARS): + return False + if segment.count("`") % 2: + return False + if any(segment.count(o) != segment.count(c) for o, c in _BRACKET_PAIRS): + return False + return len(segment.split()) >= 3 + + def segment_source( text: str, *, @@ -51,17 +85,18 @@ def segment_source( ) -> list[str]: """Deterministic split into candidate claim spans, each a verbatim substring. - Order-preserving and de-duplicated. Drops fragments that are too short, too - long, or mostly markup. Whitespace-stripping is safe for the receipt: the - stripped span is still a contiguous byte run in the source. + Order-preserving and de-duplicated. Drops fragments that are too short, + too long, mostly markup, or not claim-worthy (unbalanced markup, + punctuation-led, fewer than three words). Whitespace-stripping is safe for + the receipt: the stripped span is still a contiguous byte run in the source. """ seen: set[str] = set() out: list[str] = [] - for match in _SEGMENT_RE.finditer(text): - segment = match.group().strip() - if not (min_chars <= len(segment) <= max_chars): + for span in _split_spans(text): + segment = span.strip() + if not segment or not (min_chars <= len(segment) <= max_chars): continue - if _is_noise(segment) or segment in seen: + if _is_noise(segment) or not _is_claimworthy(segment) or segment in seen: continue seen.add(segment) out.append(segment) diff --git a/src/vouch/hooks.py b/src/vouch/hooks.py index 6fef47fd..2838089a 100644 --- a/src/vouch/hooks.py +++ b/src/vouch/hooks.py @@ -12,6 +12,7 @@ import json import logging +import re from typing import Any import yaml @@ -25,6 +26,57 @@ _MAX_ITEMS = 8 _MAX_CHARS = 2000 +_TOKEN_RE = re.compile(r"\w+") + +# Retrieval ORs every query token (see index_db._quote_match), so a prompt +# like "which one is better?" matched claims on "one" and injected noise on +# every conversational turn. Search only on informative tokens; a prompt +# with none injects nothing at all. +_STOPWORDS = frozenset( + [ + "a", "an", "the", "this", "that", "these", "those", "there", "here", "some", + "any", "each", "every", "either", "neither", "both", "few", "more", "most", + "other", "such", "own", "same", "all", "only", "very", "too", "not", "no", + "nor", "one", "ones", "two", "first", "second", "also", "even", "still", + "ever", "never", "always", "already", "i", "me", "my", "mine", "we", "us", + "our", "ours", "you", "your", "yours", "he", "him", "his", "she", "her", + "hers", "it", "its", "they", "them", "their", "theirs", "myself", "yourself", + "ourselves", "themselves", "itself", "am", "is", "are", "was", "were", "be", + "been", "being", "do", "does", "did", "doing", "done", "have", "has", "had", + "having", "will", "would", "shall", "should", "can", "could", "may", "might", + "must", "ought", "and", "or", "but", "so", "yet", "if", "then", "else", + "because", "while", "until", "although", "when", "where", "why", "how", + "what", "which", "who", "whom", "whose", "than", "as", "of", "to", "in", + "on", "at", "by", "for", "with", "about", "against", "between", "into", + "through", "during", "before", "after", "above", "below", "from", "up", + "down", "out", "off", "over", "under", "again", "further", "once", "think", + "thinks", "thinking", "thought", "want", "wants", "wanted", "wanna", "need", + "needs", "needed", "really", "honestly", "actually", "just", "like", "maybe", + "perhaps", "please", "okay", "ok", "yes", "yeah", "hey", "hi", "hello", + "thanks", "thank", "sure", "kind", "kinda", "sort", "sorta", "stuff", + "thing", "things", "good", "better", "best", "great", "nice", "way", "ways", + "lot", "lots", "bit", "gonna", "gotta", "lets", "let", "make", "makes", + "made", "making", "get", "gets", "got", "getting", "go", "goes", "going", + "gone", "went", "come", "comes", "came", "coming", "know", "knows", "knew", + "knowing", "see", "sees", "saw", "seen", "look", "looks", "looked", + "looking", "tell", "tells", "told", "say", "says", "said", "much", "many", + "little", "anything", "something", "everything", "nothing", "anyone", + "someone", "everyone", + ] +) + + +def _informative_tokens(prompt: str) -> list[str]: + """Order-preserving informative tokens: no stopwords, digits, one-char.""" + seen: set[str] = set() + out: list[str] = [] + for tok in _TOKEN_RE.findall(prompt.lower()): + if len(tok) < 2 or tok.isdigit() or tok in _STOPWORDS or tok in seen: + continue + seen.add(tok) + out.append(tok) + return out + def _render(pack: dict[str, Any]) -> str: lines: list[str] = [] @@ -66,10 +118,13 @@ def build_claude_prompt_hook(store: KBStore, stdin_text: str) -> str: # Best-effort reflex feed: recording must never break a # working hook (module contract -- see docstring). _log.warning("context-hook: salience record_query failed", exc_info=True) + tokens = _informative_tokens(prompt) + if not tokens: + return "" try: pack = build_context_pack( store, - query=prompt, + query=" ".join(tokens), limit=_MAX_ITEMS, max_chars=_MAX_CHARS, ) diff --git a/tests/test_extract.py b/tests/test_extract.py index 1af16bba..d64e5b19 100644 --- a/tests/test_extract.py +++ b/tests/test_extract.py @@ -48,6 +48,50 @@ def test_segment_source_drops_short_noise_and_dupes() -> None: assert all(any(c.isalpha() for c in s) for s in segs) # no pure-symbol spans +def test_segment_source_keeps_dotted_tokens_intact() -> None: + """Periods inside versions, filenames, and links are not sentence ends. + + Regression for the fragment-claim factory: splitting "1.2.0" or + "cli.py:2550" on every dot minted approved claims like + "0`, and let release." — a boundary needs trailing whitespace. + """ + text = ( + "vouch 1.2.0 shipped to pypi yesterday afternoon. " + "the parser bug lives in src/vouch/cli.py:2550 near the top. " + "see [the changelog](https://github.com/vouchdev/vouch/blob/main/" + "CHANGELOG.md) for details on it." + ) + segs = extract.segment_source(text) + assert "vouch 1.2.0 shipped to pypi yesterday afternoon." in segs + assert "the parser bug lives in src/vouch/cli.py:2550 near the top." in segs + assert ( + "see [the changelog](https://github.com/vouchdev/vouch/blob/main/" + "CHANGELOG.md) for details on it." in segs + ) + assert len(segs) == 3 + + +def test_segment_source_drops_unbalanced_markup_fragments() -> None: + """A span with dangling backticks or brackets is a shard, not a claim.""" + text = ( + "0`, and let release. " + "py:89](/tmp/wt/src/vouch/hooks. " + "real sentences survive the coherence gate just fine." + ) + segs = extract.segment_source(text) + assert segs == ["real sentences survive the coherence gate just fine."] + + +def test_segment_source_drops_punctuation_led_and_single_word_spans() -> None: + text = ( + ", and the rest of a mangled sentence trails off here. " + "supercalifragilisticexpialidocious. " + "a full proposition stands on its own feet." + ) + segs = extract.segment_source(text) + assert segs == ["a full proposition stands on its own feet."] + + def test_extract_receipt_claims_files_verifying_claims(store: KBStore) -> None: src = store.put_source(SOURCE) filed = extract.extract_receipt_claims(store, src.id, proposed_by="agent") diff --git a/tests/test_hooks.py b/tests/test_hooks.py index 5ba4a32d..637cdcda 100644 --- a/tests/test_hooks.py +++ b/tests/test_hooks.py @@ -57,6 +57,62 @@ def test_no_hits_injects_nothing(store: KBStore, monkeypatch: pytest.MonkeyPatch assert hooks.build_claude_prompt_hook(store, json.dumps({"prompt": "zzznomatch"})) == "" +def test_conversational_prompt_with_no_informative_tokens_injects_nothing( + store: KBStore, monkeypatch: pytest.MonkeyPatch +) -> None: + """A prompt made of stopwords must inject zero tokens of context. + + Regression for the «one»/«better» noise: FTS ORs every prompt token, so + conversational prompts matched claims on words like "one" and filled the + block with irrelevant snippets on every turn. + """ + monkeypatch.setattr( + context.index_db, + "search", + lambda *a, **k: [("claim", "c1", "deploys run on «one» tuesday", 2.0)], + ) + monkeypatch.setattr(context.index_db, "search_semantic", lambda *a, **k: []) + out = hooks.build_claude_prompt_hook( + store, json.dumps({"prompt": "honestly think, which one is better you think?"}) + ) + assert out == "" + + +def test_stopwords_are_stripped_from_the_retrieval_query( + store: KBStore, monkeypatch: pytest.MonkeyPatch +) -> None: + seen: dict[str, str] = {} + + def _spy(*a: object, **k: object) -> dict[str, list[object]]: + seen["query"] = str(k["query"]) + return {"items": []} + + monkeypatch.setattr(hooks, "build_context_pack", _spy) + hooks.build_claude_prompt_hook( + store, json.dumps({"prompt": "when do the deploys run in ci?"}) + ) + assert seen["query"] == "deploys run ci" + + +def test_claim_hits_render_full_text_not_snippet_windows(store: KBStore) -> None: + """The injected block carries the whole approved claim, not the FTS5 + 16-token «»-highlighted window — elided snippets are a search-UI + affordance and read as garbage in model context.""" + long_text = ( + "the release workflow pushes a version tag, waits for ci to go green, " + "and then publishes the wheel to pypi with trusted publishing enabled" + ) + src = store.put_source(b"more evidence") + store.put_claim(Claim(id="c9", text=long_text, evidence=[src.id])) + health.rebuild_index(store) + out = hooks.build_claude_prompt_hook( + store, json.dumps({"prompt": "how does the release workflow publish to pypi"}) + ) + ctx = json.loads(out)["hookSpecificOutput"]["additionalContext"] + assert long_text in ctx + assert "«" not in ctx and "»" not in ctx + + def test_non_dict_json_payload_is_safe(store: KBStore) -> None: for raw in ("null", "42", "true", "[1,2,3]", '"a string"'): assert hooks.build_claude_prompt_hook(store, raw) == "" @@ -69,7 +125,9 @@ def _boom(*a: object, **k: object) -> object: raise RuntimeError("boom") monkeypatch.setattr(hooks, "build_context_pack", _boom) - assert hooks.build_claude_prompt_hook(store, json.dumps({"prompt": "x"})) == "" + # an informative prompt, so the call actually reaches build_context_pack + # (a stopword-only prompt would short-circuit before the exception path). + assert hooks.build_claude_prompt_hook(store, json.dumps({"prompt": "deploys"})) == "" def test_context_hook_cli_always_exits_zero_without_kb(