Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/vouch/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
57 changes: 46 additions & 11 deletions src/vouch/extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -37,12 +38,45 @@
# the characters to be letters.
_MIN_LETTER_RATIO = 0.5

_OPENING_CHARS = "\"'`([{"
_BRACKET_PAIRS = (("(", ")"), ("[", "]"), ("{", "}"))
Comment on lines +41 to +42

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

allow markdown list bullets and blockquotes.

this gate drops valid propositions if they start with markdown formatting characters like -, *, or >, as they are not included in _opening_chars. this will silently discard valid list items, blockquotes, and headers when _is_claimworthy checks the first character. consider adding common markdown prefix characters to _opening_chars to preserve these structures.

🐛 proposed fix to preserve markdown lists
-_OPENING_CHARS = "\"'`([{"
+_OPENING_CHARS = "\"'`([{*-+>#"
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
_OPENING_CHARS = "\"'`([{"
_BRACKET_PAIRS = (("(", ")"), ("[", "]"), ("{", "}"))
_OPENING_CHARS = "\"'`([{*-+>#"
_BRACKET_PAIRS = (("(", ")"), ("[", "]"), ("{", "}"))
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/vouch/extract.py` around lines 41 - 42, Update the opening-character gate
used by _is_claimworthy to recognize common Markdown prefix characters,
including list bullets and blockquotes such as -, *, and >. Add these characters
to _OPENING_CHARS while preserving the existing bracket-pair handling and claim
evaluation behavior.



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,
*,
Expand All @@ -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)
Expand Down
57 changes: 56 additions & 1 deletion src/vouch/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

import json
import logging
import re
from typing import Any

import yaml
Expand All @@ -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] = []
Expand Down Expand Up @@ -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,
)
Expand Down
44 changes: 44 additions & 0 deletions tests/test_extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
60 changes: 59 additions & 1 deletion tests/test_hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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) == ""
Expand All @@ -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(
Expand Down
Loading