Skip to content

feat(embeddings): semantic-default build_context_pack with --explain#43

Merged
plind-junior merged 4 commits into
vouchdev:mainfrom
dripsmvcp:feat/semantic-search-phase-7-context-pack
May 25, 2026
Merged

feat(embeddings): semantic-default build_context_pack with --explain#43
plind-junior merged 4 commits into
vouchdev:mainfrom
dripsmvcp:feat/semantic-search-phase-7-context-pack

Conversation

@dripsmvcp

@dripsmvcp dripsmvcp commented May 20, 2026

Copy link
Copy Markdown
Contributor

Stacked on Phase 6 (feat/semantic-search-phase-6-capabilities).

Summary

Phase 7: route build_context_pack (the agent-facing context assembly) through semantic retrieval by default. Adds an explain=True mode that returns a per-result score breakdown alongside the assembled pack.

Tracks Task 23 in the plan.

Commit

Commit Purpose
3228619 Refactor _retrieve() to dispatch embedding → FTS5 → substring; add explain parameter; surface backend in the returned pack; update three downstream callers (cli.py, server.py, jsonl_server.py) for the new return shape

Design notes

Refactored _retrieve() instead of inlining in build_context_pack. The existing helper already isolated retrieval; cleaner to update it in place rather than push the dispatch into the assembly function. Hits are now annotated with their backend source for downstream --explain reporting.

_enrich_summary() (new helper) backfills snippets. search_embedding returns "" as the snippet (the embedding backend only knows IDs and scores). Without this enrichment, the existing max_chars budget logic in build_context_pack would over-include items because empty snippets contribute 0 chars. _enrich_summary falls back to the stored artifact's text (claim.text / page.title / entity.name) when the snippet is empty.

Return shape: always a dict. build_context_pack now returns pack.model_dump() rather than the ContextPack model. Reason: the new tests use .get("items", []) without explain=True and the explain field needs to be addable; mixed Pydantic + dict was ugly. Existing callers in cli.py / server.py / jsonl_server.py already called .model_dump(mode="json") on the result; that's been simplified now that build_context_pack returns a dict directly.

Test Plan

  • .venv/bin/pytest tests/test_context.py -v5 passed (3 existing updated for dict-style access + 2 new for semantic default and explain)
  • .venv/bin/pytest --ignore=tests/test_sessions.py -q115 passed
  • .venv/bin/python -m ruff check src/vouch/context.py tests/test_context.pyclean (3 import-order auto-fixes in tests)

Callers updated

  • src/vouch/cli.py — drops the redundant .model_dump(mode="json")
  • src/vouch/server.py — same
  • src/vouch/jsonl_server.py — same

What's NOT in this PR

  • --explain is plumbed through the API but not yet a CLI flag on vouch search — Phase 8 adds it. The MCP / JSONL tools accept explain=True immediately.

Summary by CodeRabbit

  • New Features
    • Search now supports backend selection (semantic embedding, FTS5, substring, or hybrid modes) and min_score filtering for semantic results.
    • Added explain mode to context retrieval displaying per-hit metadata breakdown.
    • Hit summaries automatically enriched from stored artifact content when empty.
    • Hybrid search merges results from multiple backends.

Review Change Stack

@coderabbitai

coderabbitai Bot commented May 20, 2026

Copy link
Copy Markdown

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This PR refactors context pack serialization and enriches hit retrieval with backend tracking. Hit retrieval now labels each result with its retrieval backend (embedding, FTS5, substring), summaries are enriched from stored artifacts when missing, and context packs optionally include per-hit metadata. JSON serialization is deferred to the presentation layer, and search dispatch now accepts explicit backend routing parameters.

Changes

Backend tracking and context pack enrichment

Layer / File(s) Summary
Deferred NumPy import for optional embeddings
src/vouch/embeddings/base.py
NumPy import moves behind TYPE_CHECKING; runtime import deferred to Embedder.encode_batch where needed.
Hit retrieval with backend labels and summary enrichment
src/vouch/context.py
Hit tuple now includes backend field; _retrieve attempts semantic search first, then FTS5 with error guard, then substring fallback; each path labels results. New _enrich_summary fills empty summaries from stored claim/page/entity content.
Context pack enrichment with backend and explain metadata
src/vouch/context.py
build_context_pack adds explain parameter and returns dict (union type) with top-level backend field and optional explain array containing per-hit metadata (kind, id, score, backend).
Server-side JSON serialization refactoring
src/vouch/cli.py, src/vouch/server.py, src/vouch/jsonl_server.py
Return raw ContextPack or dict from build_context_pack; defer JSON encoding to _emit_json. Removes early .model_dump(mode="json") calls.
Backend-aware search routing and hit labeling
src/vouch/jsonl_server.py
_h_search accepts params.backend (auto, embedding, fts5, substring, hybrid) and params.min_score; auto tries semantic → FTS5 → substring; hybrid runs both with larger limits and merges via rrf_fuse. Returned hits include backend field.
Tests: context pack enrichment and backend tracking
tests/test_context.py
Register MockEmbedder autouse fixture; update quality assertions to dict-style access; add tests for semantic default and explain=True flag with backend metadata.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

  • vouchdev/vouch#25: The main PR's src/vouch/jsonl_server.py kb.search changes to explicitly control retrieval backends and return per-hit backend labels directly build on the retrieved PR's fix to ensure substring fallback results aren't mislabeled as "fts5".

Poem

🐰 The backends now wear their badges bright,
Each hit remembers how it came to light—
Semantic, FTS5, substring's cheer,
With metadata trails crystal clear!
NumPy defers with grace and style,
Context flows enriched all the while.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 15.44% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat(embeddings): semantic-default build_context_pack with --explain' directly and accurately summarizes the main changes: making semantic retrieval the default for build_context_pack and adding explain functionality.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 11

🧹 Nitpick comments (1)
src/vouch/embeddings/dedup.py (1)

74-76: 💤 Low value

Dot product assumes unit vectors for cosine similarity.

vecs[k1] @ vecs[k2] equals cosine similarity only when vectors are L2-normalized. If the embedder produces non-normalized vectors, scores will be incorrect. Consider adding a note or normalizing explicitly if this invariant isn't guaranteed upstream.

🤖 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/embeddings/dedup.py` around lines 74 - 76, The code treats vecs[k1]
@ vecs[k2] as cosine similarity but that only holds for L2-normalized vectors;
update the logic around the line where cos = float(vecs[k1] @ vecs[k2]) to
compute true cosine similarity by either L2-normalizing vectors (e.g., normalize
every vector in vecs once before the pairwise loop) or replace the dot with
dot/(norm(v1)*norm(v2)) using the same vecs, and keep references to vecs, k1, k2
and the cos variable so the change is applied exactly where the pairwise
similarity is computed.
🤖 Prompt for all review comments with 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.

Inline comments:
In `@docs/superpowers/specs/2026-05-20-semantic-search-design.md`:
- Around line 213-227: The heading "13. Test plan (~900 LOC across 9 files)" is
out of sync with the table which lists 10 test files (notably
`tests/test_embeddings_cli.py`); update the heading to the correct file count
(change "9 files" to "10 files") or remove the extraneous table row if
`tests/test_embeddings_cli.py` should not be included so the heading and table
match. Ensure you edit the heading text "13. Test plan (~900 LOC across 9
files)" and/or the table entry for `tests/test_embeddings_cli.py` accordingly.
- Around line 33-47: The fenced code block listing the embeddings module files
triggers markdownlint MD040 because it has no language; update the opening fence
from ``` to ```text (or another appropriate language token) so the block becomes
```text and keep the existing contents unchanged; this change applies to the
code block containing the "src/vouch/embeddings/" listing in the
semantic-search-design.md document.

In `@pyproject.toml`:
- Around line 36-49: The dev extras in pyproject.toml are missing numpy, causing
mypy import-not-found for the unconditional "import numpy" in
src/vouch/embeddings/*.py; update the pyproject.toml dev extras to include the
same numpy spec used in the embeddings extras (e.g., "numpy>=1.26,<3") so that
CI installs numpy with pip install -e '.[dev]' and mypy type-checking succeeds.

In `@src/vouch/context.py`:
- Around line 65-67: The code path handling kind == "entity" calls
store.get_entity(artifact_id) and returns e.name or e.description[:200], which
can raise TypeError if e.description is None; update this branch in
src/vouch/context.py (the logic around kind == "entity", variable e from
store.get_entity) to guard against None by using a safe fallback before slicing
(e.g., use an empty string or coalesce e.description) so the expression becomes
something like e.name or (e.description or "")[:200]; ensure the change
preserves the existing short-description behavior.

In `@src/vouch/embeddings/dedup.py`:
- Line 9: CI mypy fails on the np.ndarray annotation in dedup.py (see symbol
np.ndarray used in deduplicate_embeddings or similar) because numpy type stubs
are missing; add "types-numpy" to the dev-dependencies in pyproject.toml
(matching the existing pattern for "types-pyyaml") so mypy can resolve numpy
types during the python -m mypy src run.

In `@src/vouch/embeddings/rerank.py`:
- Around line 43-47: The reranker currently builds reranked using zip(...,
strict=False) which can silently drop entries when the returned scores length
differs from hits; update the code around reranker.score(...) and the
construction of reranked to explicitly validate that len(scores) == len(hits)
and raise a clear exception (include context like query and counts) if they
differ, then proceed to build reranked (or you may switch zip to strict=True
after the length check) so mismatches fail fast; reference the
variables/function names scores, hits, reranked, and reranker.score.

In `@src/vouch/embeddings/scorer.py`:
- Around line 54-67: Validate the incoming metrics list against the supported
set before initializing totals: check that each entry in metrics is one of
{"recall@k","mrr","ndcg"} and raise a clear error (e.g. ValueError) if any
unknown metric is present, rather than silently initializing totals for
unsupported names; then proceed to build totals = {m:0.0 for m in metrics} and
the loop using recall_at_k, mrr, ndcg as before (references: variables metrics,
totals, queries_file, index_db.search_semantic, recall_at_k, mrr, ndcg, and
parameter k).

In `@src/vouch/index_db.py`:
- Around line 185-192: mypy is failing because numpy is an optional dependency;
update static type checking to ignore missing numpy stubs by adding a mypy
override for the "numpy" module (ignore_missing_imports = true) in your
pyproject.toml so functions like _vec_to_blob and _blob_to_vec no longer trigger
import-not-found errors during CI; alternatively, if you prefer a local change,
add a per-file import ignore for numpy (e.g., add a # type:
ignore[import-not-found] on the numpy import lines) to silence mypy.
- Around line 248-271: The cache uses id(conn) in _sqlite_vec_loaded so a new
sqlite3.Connection can reuse a freed object's id and incorrectly skip loading;
update _load_sqlite_vec to mark the connection itself after successful load
(e.g., set an attribute on conn like conn._sqlite_vec_loaded = True) or use a
WeakSet instead of _sqlite_vec_loaded, check that attribute/WeakSet membership
on the actual conn object rather than id(conn), and ensure you still disable
extension loading in the finally block and add the connection to the chosen
cache mechanism only after a successful sqlite_vec.load(conn).
- Around line 294-301: The SQL references the SELECT alias "score" in the WHERE
and ORDER BY clauses (in the conn.execute call that assigns rows), which SQLite
disallows; update the query to either (a) repeat the full expression 1.0 -
vec_distance_cosine(vec, ?) where "score" is used (i.e., use WHERE kind IN (...)
AND 1.0 - vec_distance_cosine(vec, ?) >= ? and ORDER BY 1.0 -
vec_distance_cosine(vec, ?) DESC) or (b) wrap the current SELECT as a subquery
(SELECT * FROM (SELECT ..., 1.0 - vec_distance_cosine(vec, ?) AS score FROM
embedding_index WHERE kind IN (...)) WHERE score >= ? ORDER BY score DESC LIMIT
?) so that the alias can be filtered; apply the change in the conn.execute call
that passes (q.tobytes(), *kinds, min_score, limit) and ensure placeholders and
parameter order remain correct.

In `@tests/embeddings/_fakes.py`:
- Around line 31-42: The encode implementation in MockEmbedder produces
non-finite floats by unpacking arbitrary SHA-256 bits into IEEE float32; to fix,
ensure any unpacked value assigned to out is finite (use np.isfinite) and
replace NaN/Inf with a stable fallback (e.g., 0.0) before incrementing i, then
continue with the existing normalization (guarding against zero norm). Update
the loop that fills out (where struct.unpack("<f", h[j:j + 4])[0] is used) to
check the value for finiteness and substitute 0.0 for non-finite values so tests
comparing arrays deterministically (np.array_equal) won't fail.

---

Nitpick comments:
In `@src/vouch/embeddings/dedup.py`:
- Around line 74-76: The code treats vecs[k1] @ vecs[k2] as cosine similarity
but that only holds for L2-normalized vectors; update the logic around the line
where cos = float(vecs[k1] @ vecs[k2]) to compute true cosine similarity by
either L2-normalizing vectors (e.g., normalize every vector in vecs once before
the pairwise loop) or replace the dot with dot/(norm(v1)*norm(v2)) using the
same vecs, and keep references to vecs, k1, k2 and the cos variable so the
change is applied exactly where the pairwise similarity is computed.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 1ef83ceb-2469-4873-8aee-e01c26561b11

📥 Commits

Reviewing files that changed from the base of the PR and between 056e88c and 3228619.

📒 Files selected for processing (34)
  • docs/superpowers/plans/2026-05-20-semantic-search.md
  • docs/superpowers/specs/2026-05-20-semantic-search-design.md
  • pyproject.toml
  • src/vouch/cli.py
  • src/vouch/context.py
  • src/vouch/embeddings/__init__.py
  • src/vouch/embeddings/base.py
  • src/vouch/embeddings/cache.py
  • src/vouch/embeddings/dedup.py
  • src/vouch/embeddings/fastembed_bge.py
  • src/vouch/embeddings/fusion.py
  • src/vouch/embeddings/hyde.py
  • src/vouch/embeddings/migration.py
  • src/vouch/embeddings/rerank.py
  • src/vouch/embeddings/scorer.py
  • src/vouch/embeddings/st_minilm.py
  • src/vouch/embeddings/st_mpnet.py
  • src/vouch/index_db.py
  • src/vouch/jsonl_server.py
  • src/vouch/server.py
  • src/vouch/storage.py
  • tests/embeddings/__init__.py
  • tests/embeddings/_fakes.py
  • tests/embeddings/test_core.py
  • tests/embeddings/test_dedup.py
  • tests/embeddings/test_fusion.py
  • tests/embeddings/test_hyde.py
  • tests/embeddings/test_integration.py
  • tests/embeddings/test_migration.py
  • tests/embeddings/test_rerank.py
  • tests/embeddings/test_scorer.py
  • tests/embeddings/test_search.py
  • tests/embeddings/test_storage.py
  • tests/test_context.py

Comment on lines +33 to +47
```
src/vouch/embeddings/
__init__.py # public API: encode, search, register
base.py # Embedder ABC + adapter registry
st_mpnet.py # default impl (sentence-transformers all-mpnet-base-v2)
st_minilm.py # alternative impl
fastembed_bge.py # alternative impl (no-torch path via fastembed)
cache.py # query embedding LRU + content-hash skip cache
rerank.py # cross-encoder reranker (ms-marco-MiniLM-L6-v2)
hyde.py # Hypothetical Document Embedding query expansion
dedup.py # cosine-threshold duplicate detection at ingest
fusion.py # RRF, weighted-sum, normalized-cosine fusion strategies
eval.py # recall@k / MRR / nDCG harness
migration.py # model-identity check + backfill orchestration
```

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Specify a language for the fenced code block.

This block triggers markdownlint MD040 and will keep docs lint noisy.

Suggested patch
-```
+```text
 src/vouch/embeddings/
   __init__.py                # public API: encode, search, register
   base.py                    # Embedder ABC + adapter registry
   st_mpnet.py                # default impl (sentence-transformers all-mpnet-base-v2)
@@
   eval.py                    # recall@k / MRR / nDCG harness
   migration.py               # model-identity check + backfill orchestration
</details>

<details>
<summary>🧰 Tools</summary>

<details>
<summary>🪛 markdownlint-cli2 (0.22.1)</summary>

[warning] 33-33: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

</details>

</details>

<details>
<summary>🤖 Prompt for AI Agents</summary>

Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @docs/superpowers/specs/2026-05-20-semantic-search-design.md around lines 33

  • 47, The fenced code block listing the embeddings module files triggers
    markdownlint MD040 because it has no language; update the opening fence from totext (or another appropriate language token) so the block becomes ```text
    and keep the existing contents unchanged; this change applies to the code block
    containing the "src/vouch/embeddings/" listing in the semantic-search-design.md
    document.

</details>

<!-- fingerprinting:phantom:poseidon:hawk -->

<!-- This is an auto-generated comment by CodeRabbit -->

Comment on lines +213 to +227
## 13. Test plan (~900 LOC across 9 files)

| File | Covers |
|---|---|
| `tests/test_embeddings_core.py` | Embedder ABC, registry, content-hash skip, batched encode, lazy load |
| `tests/test_embeddings_storage.py` | vec0 + sqlite-vec round trip + NumPy fallback parity |
| `tests/test_embeddings_search.py` | Semantic primary, FTS5 fallback, lexical-disjoint regression |
| `tests/test_embeddings_fusion.py` | RRF, weighted, normalized fusion strategies correctness |
| `tests/test_embeddings_rerank.py` | Cross-encoder rerank changes top-K order on a known pair |
| `tests/test_embeddings_hyde.py` | HyDE expansion improves recall on terse queries |
| `tests/test_embeddings_dedup.py` | Threshold ledger + audit event |
| `tests/test_embeddings_migration.py` | Model-version mismatch + backfill flow |
| `tests/test_embeddings_eval.py` | recall@k / MRR / nDCG correctness on synthetic ground truth |
| `tests/test_embeddings_cli.py` | CLI flag routing |

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Fix the test-plan count mismatch.

The heading says “9 files” but the table currently lists 10 files (including tests/test_embeddings_cli.py).

🤖 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 `@docs/superpowers/specs/2026-05-20-semantic-search-design.md` around lines 213
- 227, The heading "13. Test plan (~900 LOC across 9 files)" is out of sync with
the table which lists 10 test files (notably `tests/test_embeddings_cli.py`);
update the heading to the correct file count (change "9 files" to "10 files") or
remove the extraneous table row if `tests/test_embeddings_cli.py` should not be
included so the heading and table match. Ensure you edit the heading text "13.
Test plan (~900 LOC across 9 files)" and/or the table entry for
`tests/test_embeddings_cli.py` accordingly.

Comment thread pyproject.toml Outdated
Comment thread src/vouch/context.py Outdated
from pathlib import Path
from typing import Any

import numpy as np

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if types-numpy is already in dev dependencies
rg -n "types-numpy" pyproject.toml setup.cfg setup.py requirements*.txt 2>/dev/null || echo "types-numpy not found in dependencies"

# Check mypy configuration for numpy handling
rg -n -A5 '\[tool\.mypy\]' pyproject.toml 2>/dev/null
rg -n 'ignore_missing_imports' pyproject.toml mypy.ini .mypy.ini setup.cfg 2>/dev/null || echo "No ignore_missing_imports found"

Repository: vouchdev/vouch

Length of output: 128


🏁 Script executed:

#!/bin/bash
# Check if pyproject.toml exists and show its full mypy configuration
if [ -f "pyproject.toml" ]; then
    echo "=== pyproject.toml exists ==="
    cat pyproject.toml | grep -A 20 "\[tool\.mypy\]" || echo "No [tool.mypy] section"
fi

# Check for mypy.ini or setup.cfg
echo ""
echo "=== Checking for other config files ==="
ls -la mypy.ini setup.cfg .mypy.ini 2>&1 | grep -v "cannot access"

# Check if numpy is imported elsewhere and how many files use it
echo ""
echo "=== Files importing numpy ==="
rg -l "import numpy" --type py

# Check if there are any existing type ignore comments for numpy
echo ""
echo "=== Existing type ignore comments ==="
rg -n "type: ignore" src/vouch/embeddings/dedup.py 2>/dev/null || echo "No type ignore comments in dedup.py"

Repository: vouchdev/vouch

Length of output: 632


🏁 Script executed:

#!/bin/bash
# Search for CI/CD configuration and mypy invocation
echo "=== GitHub Actions workflows ==="
find .github/workflows -type f -name "*.yml" -o -name "*.yaml" 2>/dev/null | head -5

echo ""
echo "=== tox.ini ==="
if [ -f "tox.ini" ]; then
    cat tox.ini | grep -A 10 "mypy\|tool.mypy" || echo "No mypy in tox.ini"
fi

echo ""
echo "=== Makefile ==="
if [ -f "Makefile" ]; then
    cat Makefile | grep -B2 -A5 "mypy" || echo "No mypy in Makefile"
fi

echo ""
echo "=== pytest.ini or pyproject.toml pytest config ==="
if [ -f "pytest.ini" ]; then
    cat pytest.ini
else
    echo "No pytest.ini"
fi

echo ""
echo "=== Search for mypy in config files ==="
rg -l "mypy" . --type yaml --type toml --type ini 2>/dev/null | head -10

echo ""
echo "=== Check if mypy is in dependencies ==="
rg "mypy" pyproject.toml setup.py setup.cfg requirements*.txt 2>/dev/null

Repository: vouchdev/vouch

Length of output: 1012


🏁 Script executed:

#!/bin/bash
# Check the CI workflow to see if mypy is required
echo "=== GitHub Actions CI workflow ==="
cat .github/workflows/ci.yml | grep -B2 -A5 "mypy\|type\|make check" || echo "Checking full workflow..."

echo ""
echo "=== Full CI workflow ==="
cat .github/workflows/ci.yml

Repository: vouchdev/vouch

Length of output: 1671


🏁 Script executed:

#!/bin/bash
# Check the actual mypy default behavior by looking at what numpy stubs would provide
echo "=== Checking dev dependencies in pyproject.toml ==="
rg -A 20 "dev.*=" pyproject.toml | head -40

echo ""
echo "=== Check numpy import usage in dedup.py ==="
cat -n src/vouch/embeddings/dedup.py | head -20

echo ""
echo "=== Check if there are any comments about mypy or numpy in the file ==="
rg -n "mypy|type.*ignore|numpy" src/vouch/embeddings/dedup.py

Repository: vouchdev/vouch

Length of output: 1205


Add types-numpy to dev dependencies to fix mypy type checking.

The CI pipeline explicitly runs python -m mypy src which will fail when encountering the np.ndarray type annotation in dedup.py (line 18) without numpy type stubs. Add types-numpy to the dev dependencies in pyproject.toml, consistent with the existing types-pyyaml pattern.

🧰 Tools
🪛 GitHub Actions: ci / 1_test (py3.11).txt

[error] 9-9: mypy: Cannot find implementation or library stub for module named "numpy" [import-not-found]

🪛 GitHub Actions: ci / 2_test (py3.12).txt

[error] 9-9: mypy error: Cannot find implementation or library stub for module named "numpy" [import-not-found]

🪛 GitHub Actions: ci / 3_test (py3.13).txt

[error] 9-9: mypy: Cannot find implementation or library stub for module named "numpy" [import-not-found]

🪛 GitHub Actions: ci / test (py3.11)

[error] 9-9: mypy error: Cannot find implementation or library stub for module named "numpy" [import-not-found]

🪛 GitHub Actions: ci / test (py3.12)

[error] 9-9: mypy: Cannot find implementation or library stub for module named "numpy" [import-not-found]

🪛 GitHub Actions: ci / test (py3.13)

[error] 9-9: mypy: Cannot find implementation or library stub for module named "numpy" [import-not-found]

🤖 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/embeddings/dedup.py` at line 9, CI mypy fails on the np.ndarray
annotation in dedup.py (see symbol np.ndarray used in deduplicate_embeddings or
similar) because numpy type stubs are missing; add "types-numpy" to the
dev-dependencies in pyproject.toml (matching the existing pattern for
"types-pyyaml") so mypy can resolve numpy types during the python -m mypy src
run.

Comment thread src/vouch/embeddings/scorer.py
Comment thread src/vouch/index_db.py
Comment thread src/vouch/index_db.py
Comment on lines +248 to +271
_sqlite_vec_loaded: set[int] = set()


def _load_sqlite_vec(conn: sqlite3.Connection) -> bool:
"""Best-effort load of the sqlite-vec extension."""
if id(conn) in _sqlite_vec_loaded:
return True
try:
conn.enable_load_extension(True)
except (AttributeError, sqlite3.OperationalError):
return False
try:
import sqlite_vec # type: ignore[import-not-found]
except ImportError:
return False
try:
sqlite_vec.load(conn)
except sqlite3.OperationalError:
return False
finally:
with suppress(sqlite3.OperationalError):
conn.enable_load_extension(False)
_sqlite_vec_loaded.add(id(conn))
return True

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Cache key id(conn) can be reused after connection is garbage collected.

Python reuses object IDs after the original object is freed. If a connection is closed and a new one happens to receive the same memory address (and thus same id()), this function will incorrectly return True without loading the extension on the new connection.

Consider using a WeakSet or attaching a marker attribute to the connection:

Proposed fix using connection attribute
-_sqlite_vec_loaded: set[int] = set()
+_SQLITE_VEC_LOADED_ATTR = "_vouch_sqlite_vec_loaded"


 def _load_sqlite_vec(conn: sqlite3.Connection) -> bool:
     """Best-effort load of the sqlite-vec extension."""
-    if id(conn) in _sqlite_vec_loaded:
+    if getattr(conn, _SQLITE_VEC_LOADED_ATTR, False):
         return True
     try:
         conn.enable_load_extension(True)
     except (AttributeError, sqlite3.OperationalError):
         return False
     try:
         import sqlite_vec  # type: ignore[import-not-found]
     except ImportError:
         return False
     try:
         sqlite_vec.load(conn)
     except sqlite3.OperationalError:
         return False
     finally:
         with suppress(sqlite3.OperationalError):
             conn.enable_load_extension(False)
-    _sqlite_vec_loaded.add(id(conn))
+    setattr(conn, _SQLITE_VEC_LOADED_ATTR, True)
     return True
🤖 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/index_db.py` around lines 248 - 271, The cache uses id(conn) in
_sqlite_vec_loaded so a new sqlite3.Connection can reuse a freed object's id and
incorrectly skip loading; update _load_sqlite_vec to mark the connection itself
after successful load (e.g., set an attribute on conn like
conn._sqlite_vec_loaded = True) or use a WeakSet instead of _sqlite_vec_loaded,
check that attribute/WeakSet membership on the actual conn object rather than
id(conn), and ensure you still disable extension loading in the finally block
and add the connection to the chosen cache mechanism only after a successful
sqlite_vec.load(conn).

Comment thread src/vouch/index_db.py
Comment thread tests/embeddings/_fakes.py Outdated
@dripsmvcp dripsmvcp force-pushed the feat/semantic-search-phase-7-context-pack branch 2 times, most recently from 5e45e42 to 28da722 Compare May 20, 2026 13:05

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 7

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/vouch/server.py (1)

139-150: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Expose explain on kb_context.

build_context_pack() now supports explain, but this MCP tool never accepts or forwards it, so agents cannot request the new per-hit breakdown through the server API.

🤖 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/server.py` around lines 139 - 150, The kb_context function
currently doesn't accept the new explain option from build_context_pack; update
kb_context signature to include an explain: bool = False parameter (or bool |
None if you prefer tri-state) and forward it to build_context_pack by adding
explain=explain to the call; ensure the docstring mentions the explain argument
and update any type hints so callers and linters accept the new parameter
(reference kb_context, build_context_pack, and _store).
src/vouch/jsonl_server.py (1)

115-124: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

kb.context drops the explain request flag.

_h_context does not forward p.get("explain"), so clients cannot request score breakdowns via JSONL even when they send it.

Proposed fix
 def _h_context(p: dict) -> dict:
     return build_context_pack(  # type: ignore[return-value]
         _store(),
         query=p["task"],
         limit=int(p.get("limit", 10)),
+        explain=bool(p.get("explain", False)),
         max_chars=p.get("max_chars"),
         min_items=int(p.get("min_items", 0)),
         require_citations=bool(p.get("require_citations", False)),
         fail_on_warnings=bool(p.get("fail_on_warnings", False)),
         fail_on_budget_truncation=bool(p.get("fail_on_budget_truncation", False)),
     )
🤖 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/jsonl_server.py` around lines 115 - 124, The handler _h_context is
dropping the "explain" request flag when calling build_context_pack; update the
call in _h_context to forward the explain parameter from p (e.g., pass
explain=p.get("explain") or explain=bool(p.get("explain", False))) so clients
can request score breakdowns; ensure the build_context_pack invocation includes
the explain argument alongside query, limit, max_chars, etc., and preserve
existing boolean conversion semantics used for other flags like
require_citations and fail_on_warnings.
♻️ Duplicate comments (1)
src/vouch/embeddings/rerank.py (1)

43-47: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Fail fast if the reranker returns the wrong number of scores.

zip(..., strict=False) will silently truncate here, so a buggy reranker can drop hits without any signal. Validate len(scores) == len(hits) before building reranked, then switch to strict=True.

🤖 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/embeddings/rerank.py` around lines 43 - 47, The code currently zips
hits with scores using zip(..., strict=False) which can silently truncate if
reranker.score returns the wrong number of scores; update the logic in rerank.py
around reranker.score(query, candidates) to validate that len(scores) ==
len(hits) immediately after calling reranker.score and raise/return an explicit
error (or log and raise an Exception) if they differ, then change the subsequent
list comprehension that builds reranked to use zip(hits, scores, strict=True) to
ensure mismatches cannot be silently ignored (refer to reranker.score, hits,
scores, and reranked in your change).
🧹 Nitpick comments (3)
tests/test_context.py (2)

63-76: ⚡ Quick win

Remove redundant embedder registration.

This test re-imports and re-registers MockEmbedder even though the autouse=True fixture at line 17 already registers it before every test. The redundant registration is unnecessary and adds maintenance overhead.

♻️ Simplify by relying on the autouse fixture
 def test_build_context_pack_uses_semantic_default(tmp_path: Path) -> None:
-    from tests.embeddings._fakes import MockEmbedder
     from vouch.context import build_context_pack
-    from vouch.embeddings import register
-    from vouch.embeddings.base import DEFAULT_MODEL_NAME
     from vouch.models import Claim
     from vouch.storage import KBStore
 
-    register(DEFAULT_MODEL_NAME, lambda: MockEmbedder(dim=8))
     store = KBStore.init(tmp_path)
     src = store.put_source(b"e")
     store.put_claim(Claim(id="c1", text="exact query string", evidence=[src.id]))
🤖 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 `@tests/test_context.py` around lines 63 - 76, The test
test_build_context_pack_uses_semantic_default redundantly re-registers the
MockEmbedder; remove the lines that import MockEmbedder and call
register(DEFAULT_MODEL_NAME, lambda: MockEmbedder(dim=8)) so the test relies on
the autouse fixture that already registers the embedder; keep the rest of the
test (store init, put_source/put_claim, call to build_context_pack, and the
assertion) unchanged.

79-95: ⚡ Quick win

Remove redundant embedder registration.

Same issue as the previous test: the autouse=True fixture already registers MockEmbedder, so re-registering it here is unnecessary.

♻️ Simplify by relying on the autouse fixture
 def test_build_context_pack_explain_flag_returns_score_breakdown(
     tmp_path: Path,
 ) -> None:
-    from tests.embeddings._fakes import MockEmbedder
     from vouch.context import build_context_pack
-    from vouch.embeddings import register
-    from vouch.embeddings.base import DEFAULT_MODEL_NAME
     from vouch.models import Claim
     from vouch.storage import KBStore
 
-    register(DEFAULT_MODEL_NAME, lambda: MockEmbedder(dim=8))
     store = KBStore.init(tmp_path)
     src = store.put_source(b"e")
     store.put_claim(Claim(id="c1", text="hello", evidence=[src.id]))
🤖 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 `@tests/test_context.py` around lines 79 - 95, The test
test_build_context_pack_explain_flag_returns_score_breakdown re-registers the
embedder redundantly; remove the explicit call to register(DEFAULT_MODEL_NAME,
lambda: MockEmbedder(dim=8)) from the test (and drop the now-unused import(s)
MockEmbedder and/or register if they become unused) so the test relies on the
autouse fixture's MockEmbedder registration instead; keep the rest of the test
(store setup, build_context_pack call, and assertions) unchanged.
src/vouch/embeddings/fusion.py (1)

22-36: ⚡ Quick win

Pre-compute list concatenation to avoid repeated allocations.

Both rrf_fuse (line 34) and weighted_fuse (line 70) call _coalesce_snippet(h, a + b) inside a loop that iterates over a + b. This creates a new concatenated list on each iteration, resulting in O(N²) list operations. Pre-compute the merged list once before the loop.

⚡ Proposed fix to pre-compute concatenation

For rrf_fuse:

 def rrf_fuse(a: Hits, b: Hits, *, limit: int = 10, k: int = 60) -> Hits:
     """Reciprocal Rank Fusion: score = sum(1 / (k + rank)) across lists."""
     scores: dict[tuple[str, str], float] = {}
     for lst in (a, b):
         for rank, h in enumerate(lst, start=1):
             scores[_key(h)] = scores.get(_key(h), 0.0) + 1.0 / (k + rank)
     out: Hits = []
     seen: set[tuple[str, str]] = set()
-    for h in a + b:
+    merged = a + b
+    for h in merged:
         if _key(h) in seen:
             continue
         seen.add(_key(h))
-        out.append((h[0], h[1], _coalesce_snippet(h, a + b), scores[_key(h)]))
+        out.append((h[0], h[1], _coalesce_snippet(h, merged), scores[_key(h)]))
     out.sort(key=lambda h: h[3], reverse=True)
     return out[:limit]

For weighted_fuse:

 def weighted_fuse(
     a: Hits,
     b: Hits,
     *,
     w_a: float = 0.5,
     w_b: float = 0.5,
     limit: int = 10,
 ) -> Hits:
     """Min-max normalize each list, then score = w_a * a_score + w_b * b_score."""
     a_norm = _minmax([h[3] for h in a])
     b_norm = _minmax([h[3] for h in b])
     a_score = {_key(h): a_norm[i] for i, h in enumerate(a)}
     b_score = {_key(h): b_norm[i] for i, h in enumerate(b)}
     merged: dict[tuple[str, str], float] = {}
     for key in {*a_score, *b_score}:
         merged[key] = w_a * a_score.get(key, 0.0) + w_b * b_score.get(key, 0.0)
     out: Hits = []
     seen: set[tuple[str, str]] = set()
-    for h in a + b:
+    merged_hits = a + b
+    for h in merged_hits:
         if _key(h) in seen:
             continue
         seen.add(_key(h))
-        out.append((h[0], h[1], _coalesce_snippet(h, a + b), merged[_key(h)]))
+        out.append((h[0], h[1], _coalesce_snippet(h, merged_hits), merged[_key(h)]))
     out.sort(key=lambda h: h[3], reverse=True)
     return out[:limit]

Also applies to: 48-72

🤖 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/embeddings/fusion.py` around lines 22 - 36, The loop in rrf_fuse
and weighted_fuse repeatedly computes a + b when calling _coalesce_snippet and
iterating, causing repeated allocations; fix by precomputing merged = a + b once
(use this merged list for the iteration and for the _coalesce_snippet calls and
when computing scores/lookups) and replace all occurrences of a + b in those
functions with merged so out/seen and score accesses use the precomputed merged
list.
🤖 Prompt for all review comments with 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.

Inline comments:
In `@src/vouch/embeddings/migration.py`:
- Around line 22-23: The comparison currently only checks current.name against
stored which lets upgrades that change version or dim slip by; update the
mismatch detection in the migration logic to also compare current.version and
current.dim (or equivalent attributes) against the stored object's version and
dim and only return None when name, version, and dim all match; otherwise treat
it as a mismatch so the migration/backfill path runs (look for the variables
current, stored, and attributes version/dim in
src/vouch/embeddings/migration.py).

In `@src/vouch/index_db.py`:
- Around line 311-314: The loop that computes score uses an unnormalized dot
product (score = q @ vec) which causes magnitude to skew ranking; change the
calculation in the rows loop (after calling _blob_to_vec) to normalize the
candidate vector (and guard against zero norm) before computing the dot with q,
or alternatively ensure vectors are normalized at write time; update the scoring
expression that uses q and vec (and any related min_score comparisons) so it
uses the normalized vec to preserve cosine semantics.

In `@src/vouch/jsonl_server.py`:
- Around line 99-105: The hybrid branch currently calls
index_db.search_semantic(s.kb_dir, q, limit=limit * 2) without honoring a
min_score filter; update the hybrid flow (the block guarded by backend_arg ==
"hybrid") to pass the caller's min_score into index_db.search_semantic (and
propagate any default/None if not provided) so semantic hits are filtered before
rrf_fuse is called; ensure you still call index_db.search(s.kb_dir, q,
limit=limit * 2) for lexical hits and then call rrf_fuse(emb, fts, limit=limit)
as before.

In `@src/vouch/server.py`:
- Around line 126-133: When backend == "hybrid" handle failures from the FTS
call so a failing index_db.search doesn't abort the whole branch: wrap the
index_db.search(store.kb_dir, query, limit=...) call in a try/except, and on
exception log or ignore the error and set fts to an empty list (or None) so you
can call rrf_fuse with only emb results (or have rrf_fuse skip FTS when fts is
empty), then return _to_dicts(hits, "hybrid") as before; update the hybrid
branch around index_db.search, rrf_fuse, and hits to degrade gracefully to
embedding-only results.

In `@src/vouch/storage.py`:
- Around line 483-485: The current skip logic in _embed_and_store uses only the
content hash (existing = _index_db.get_embedding(self.kb_dir, kind=kind, id=id);
if existing is not None and existing[1] == h: return) which prevents rewrites
when the embedder/model changes; update _embed_and_store (and the call path from
backfill_embeddings) to either accept/thread a force boolean or expand the
stored metadata check to include the embedder model identifier, model version,
and vector dimension from existing (e.g., existing metadata fields) and only
skip when both the content hash and model/version/dim match; ensure
backfill_embeddings can pass force=True to force re-embedding and that
_index_db.get_embedding accessors and the storage schema are consulted/updated
so you can compare model/version/dim before returning early.
- Around line 486-509: The embedding steps after persisting the file must not
raise — wrap the call to embedder.encode(...) and all downstream calls
(_index_db.put_embedding, _index_db.set_embedding_meta, and dedup.check_and_log)
in try/except so any exception is caught, logged, and suppressed; if encode
fails, skip calling put_embedding/set_embedding_meta/check_and_log entirely
(ensure vec is only used when encode succeeded), and do not re-raise errors from
those functions so the write path remains successful. Use existing symbols to
locate changes: get_embedder, embedder.encode, _index_db.put_embedding,
_index_db.set_embedding_meta, check_and_log, self.kb_dir, kind, id, vec, and h.

In `@tests/test_context.py`:
- Line 9: The module-level import of MockEmbedder causes pytest collection
failures when numpy/embeddings extras are missing; remove the top-level "from
tests.embeddings._fakes import MockEmbedder" and instead import MockEmbedder
lazily inside the _mock_embedder fixture function so the import happens at test
runtime (within the _mock_embedder fixture) and will be skipped when the
embeddings tests are skipped; update the _mock_embedder fixture to perform "from
tests.embeddings._fakes import MockEmbedder" at its start and use that local
symbol.

---

Outside diff comments:
In `@src/vouch/jsonl_server.py`:
- Around line 115-124: The handler _h_context is dropping the "explain" request
flag when calling build_context_pack; update the call in _h_context to forward
the explain parameter from p (e.g., pass explain=p.get("explain") or
explain=bool(p.get("explain", False))) so clients can request score breakdowns;
ensure the build_context_pack invocation includes the explain argument alongside
query, limit, max_chars, etc., and preserve existing boolean conversion
semantics used for other flags like require_citations and fail_on_warnings.

In `@src/vouch/server.py`:
- Around line 139-150: The kb_context function currently doesn't accept the new
explain option from build_context_pack; update kb_context signature to include
an explain: bool = False parameter (or bool | None if you prefer tri-state) and
forward it to build_context_pack by adding explain=explain to the call; ensure
the docstring mentions the explain argument and update any type hints so callers
and linters accept the new parameter (reference kb_context, build_context_pack,
and _store).

---

Duplicate comments:
In `@src/vouch/embeddings/rerank.py`:
- Around line 43-47: The code currently zips hits with scores using zip(...,
strict=False) which can silently truncate if reranker.score returns the wrong
number of scores; update the logic in rerank.py around reranker.score(query,
candidates) to validate that len(scores) == len(hits) immediately after calling
reranker.score and raise/return an explicit error (or log and raise an
Exception) if they differ, then change the subsequent list comprehension that
builds reranked to use zip(hits, scores, strict=True) to ensure mismatches
cannot be silently ignored (refer to reranker.score, hits, scores, and reranked
in your change).

---

Nitpick comments:
In `@src/vouch/embeddings/fusion.py`:
- Around line 22-36: The loop in rrf_fuse and weighted_fuse repeatedly computes
a + b when calling _coalesce_snippet and iterating, causing repeated
allocations; fix by precomputing merged = a + b once (use this merged list for
the iteration and for the _coalesce_snippet calls and when computing
scores/lookups) and replace all occurrences of a + b in those functions with
merged so out/seen and score accesses use the precomputed merged list.

In `@tests/test_context.py`:
- Around line 63-76: The test test_build_context_pack_uses_semantic_default
redundantly re-registers the MockEmbedder; remove the lines that import
MockEmbedder and call register(DEFAULT_MODEL_NAME, lambda: MockEmbedder(dim=8))
so the test relies on the autouse fixture that already registers the embedder;
keep the rest of the test (store init, put_source/put_claim, call to
build_context_pack, and the assertion) unchanged.
- Around line 79-95: The test
test_build_context_pack_explain_flag_returns_score_breakdown re-registers the
embedder redundantly; remove the explicit call to register(DEFAULT_MODEL_NAME,
lambda: MockEmbedder(dim=8)) from the test (and drop the now-unused import(s)
MockEmbedder and/or register if they become unused) so the test relies on the
autouse fixture's MockEmbedder registration instead; keep the rest of the test
(store setup, build_context_pack call, and assertions) unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 07811c98-ba2f-4c35-9d66-5ca730bf72f0

📥 Commits

Reviewing files that changed from the base of the PR and between 3228619 and 28da722.

📒 Files selected for processing (25)
  • pyproject.toml
  • src/vouch/cli.py
  • src/vouch/context.py
  • src/vouch/embeddings/__init__.py
  • src/vouch/embeddings/cache.py
  • src/vouch/embeddings/dedup.py
  • src/vouch/embeddings/fusion.py
  • src/vouch/embeddings/hyde.py
  • src/vouch/embeddings/migration.py
  • src/vouch/embeddings/rerank.py
  • src/vouch/embeddings/scorer.py
  • src/vouch/index_db.py
  • src/vouch/jsonl_server.py
  • src/vouch/server.py
  • src/vouch/storage.py
  • tests/embeddings/conftest.py
  • tests/embeddings/test_dedup.py
  • tests/embeddings/test_fusion.py
  • tests/embeddings/test_hyde.py
  • tests/embeddings/test_migration.py
  • tests/embeddings/test_rerank.py
  • tests/embeddings/test_scorer.py
  • tests/embeddings/test_search.py
  • tests/embeddings/test_storage.py
  • tests/test_context.py

Comment on lines +22 to +23
if current.name == stored:
return None

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Compare version and dimension too when detecting mismatches.

This only checks the adapter name. A model upgrade that keeps the same name but changes version or dim will currently return None, so migration/backfill won't trigger even though the stored vectors are stale or incompatible.

🤖 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/embeddings/migration.py` around lines 22 - 23, The comparison
currently only checks current.name against stored which lets upgrades that
change version or dim slip by; update the mismatch detection in the migration
logic to also compare current.version and current.dim (or equivalent attributes)
against the stored object's version and dim and only return None when name,
version, and dim all match; otherwise treat it as a mismatch so the
migration/backfill path runs (look for the variables current, stored, and
attributes version/dim in src/vouch/embeddings/migration.py).

Comment thread src/vouch/index_db.py
Comment thread src/vouch/jsonl_server.py
Comment thread src/vouch/server.py
Comment thread src/vouch/storage.py Outdated
Comment thread src/vouch/storage.py
Comment thread tests/test_context.py Outdated
@dripsmvcp dripsmvcp force-pushed the feat/semantic-search-phase-7-context-pack branch 2 times, most recently from 80ced1d to 75092e7 Compare May 20, 2026 13:16
dripsmvcp added a commit to dripsmvcp/vouch that referenced this pull request May 20, 2026
Per CodeRabbit Critical on PR vouchdev#43: tests/test_context.py imports
`tests.embeddings._fakes.MockEmbedder` at module top, and _fakes.py
imports numpy. CI's base `[dev]` install lacks numpy, so test
collection of test_context.py fails before any test runs.

Move the MockEmbedder import inside the `_mock_embedder` autouse
fixture and guard with `pytest.importorskip("numpy")`. Tests that
depend on the fixture skip cleanly when numpy is unavailable;
existing context tests that don't actually need it (none currently,
since the fixture is autouse) still skip with a clear reason rather
than crash collection.
@dripsmvcp dripsmvcp force-pushed the feat/semantic-search-phase-7-context-pack branch from 75092e7 to 45115d2 Compare May 20, 2026 13:45
dripsmvcp added a commit to dripsmvcp/vouch that referenced this pull request May 21, 2026
Per CodeRabbit Critical on PR vouchdev#43: tests/test_context.py imports
`tests.embeddings._fakes.MockEmbedder` at module top, and _fakes.py
imports numpy. CI's base `[dev]` install lacks numpy, so test
collection of test_context.py fails before any test runs.

Move the MockEmbedder import inside the `_mock_embedder` autouse
fixture and guard with `pytest.importorskip("numpy")`. Tests that
depend on the fixture skip cleanly when numpy is unavailable;
existing context tests that don't actually need it (none currently,
since the fixture is autouse) still skip with a clear reason rather
than crash collection.
@dripsmvcp dripsmvcp force-pushed the feat/semantic-search-phase-7-context-pack branch from 45115d2 to eb0a02c Compare May 21, 2026 05:34
dripsmvcp added a commit to dripsmvcp/vouch that referenced this pull request May 21, 2026
Per CodeRabbit Critical on PR vouchdev#43: tests/test_context.py imports
`tests.embeddings._fakes.MockEmbedder` at module top, and _fakes.py
imports numpy. CI's base `[dev]` install lacks numpy, so test
collection of test_context.py fails before any test runs.

Move the MockEmbedder import inside the `_mock_embedder` autouse
fixture and guard with `pytest.importorskip("numpy")`. Tests that
depend on the fixture skip cleanly when numpy is unavailable;
existing context tests that don't actually need it (none currently,
since the fixture is autouse) still skip with a clear reason rather
than crash collection.
@dripsmvcp dripsmvcp force-pushed the feat/semantic-search-phase-7-context-pack branch from eb0a02c to 838b702 Compare May 21, 2026 05:38

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 6

♻️ Duplicate comments (2)
src/vouch/embeddings/migration.py (1)

22-23: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Compare model version/dimension in mismatch detection.

This currently treats matching adapter names as compatible even if version or dim changed, so stale/incompatible vectors may not be backfilled.

Suggested fix
-    if current.name == stored:
+    if (
+        current.name == stored
+        and current.version == meta.get("embedding_model_version")
+        and current.dim == meta.get("embedding_dim")
+    ):
         return None
🤖 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/embeddings/migration.py` around lines 22 - 23, The check that
currently returns None when adapters match by name only (if current.name ==
stored) should be expanded to also verify model version and embedding dimension;
update the mismatch detection in migration logic (the function handling adapter
compatibility in src/vouch/embeddings/migration.py) to compare current.version
and current.dim against the stored counterpart (e.g., stored.version and
stored.dim or stored["version"]/stored["dim"] depending on stored shape) and
only return None when name, version, and dim all match; if any of those differ,
treat it as incompatible so vectors will be backfilled.
src/vouch/embeddings/scorer.py (1)

54-67: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Validate metrics input and reject unsupported names.

Unknown metric names currently return silent zeros, which can mask typos and invalidate evaluation output.

Suggested fix
 def evaluate(
@@
 ) -> dict[str, float]:
     """Run a metric sweep over a JSONL queries file."""
     from .. import index_db
+    supported = {"recall@k", "mrr", "ndcg"}
+    unknown = set(metrics) - supported
+    if unknown:
+        raise ValueError(f"Unsupported metrics: {sorted(unknown)}")
     totals = {m: 0.0 for m in metrics}
🤖 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/embeddings/scorer.py` around lines 54 - 67, The code silently
accepts unknown metric names in the metrics iterable (used to build totals and
drive calls to recall_at_k, mrr, ndcg), so add validation at the start of the
scoring routine in scorer.py: define the allowed set {"recall@k","mrr","ndcg"},
compute unsupported = set(metrics) - allowed, and if non-empty raise a
ValueError listing the unsupported names; then initialize totals only for
supported metrics (e.g. totals = {m:0.0 for m in metrics if m in allowed}) so
you don't silently accumulate zeros for misspelled/unsupported metrics.
🧹 Nitpick comments (4)
src/vouch/embeddings/hyde.py (1)

15-17: ⚡ Quick win

Normalize query consistently before branching.

query.strip() is used for the length check and template path, but the pass-through path returns the untrimmed string. This can create avoidable embedding/cache divergence for whitespace-only differences.

Proposed diff
 def expand_query_template(query: str, *, min_chars: int = 20) -> str:
     """Pad short queries with HyDE template; pass through long ones."""
-    if len(query.strip()) >= min_chars:
-        return query
-    return DEFAULT_TEMPLATE.format(query=query.strip())
+    q = query.strip()
+    if len(q) >= min_chars:
+        return q
+    return DEFAULT_TEMPLATE.format(query=q)
🤖 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/embeddings/hyde.py` around lines 15 - 17, Normalize the query once
before branching: compute a stripped version of query (e.g., q = query.strip())
and use q for the length check and for the return value so both the pass-through
and templated paths return the trimmed string; update the code that currently
calls query.strip() twice and returns the original query to instead use the
single normalized variable when returning directly or formatting
DEFAULT_TEMPLATE, referencing the existing DEFAULT_TEMPLATE and min_chars
checks.
tests/embeddings/test_migration.py (1)

54-55: ⚡ Quick win

Strengthen the backfill assertion to cover both inserted claims.

assert n >= 2 plus a single c1 check can still pass if c2 is missed. Add an explicit assertion for c2 so this test actually verifies both claim re-encodes.

Suggested test hardening
     n = backfill_embeddings(store)
     assert n >= 2
     assert index_db.get_embedding(store.kb_dir, kind="claim", id="c1") is not None
+    assert index_db.get_embedding(store.kb_dir, kind="claim", id="c2") is not None
🤖 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 `@tests/embeddings/test_migration.py` around lines 54 - 55, The test currently
asserts a count n >= 2 and validates only index_db.get_embedding(store.kb_dir,
kind="claim", id="c1"), which can miss a failed backfill for the second claim;
update the test to also assert that index_db.get_embedding(store.kb_dir,
kind="claim", id="c2") is not None (i.e., explicitly check both "c1" and "c2")
so both inserted claims are verified as re-encoded.
src/vouch/context.py (1)

155-159: 💤 Low value

Explain block uses hits[0][4] instead of per-hit _be variable.

The loop already unpacks the backend into _be but then ignores it, using hits[0][4] instead. While this works because _retrieve returns the same backend for all hits, it's confusing and will break if that assumption changes.

♻️ Clearer implementation
     if explain:
         result["explain"] = [
-            {"kind": k, "id": i, "score": sc, "backend": hits[0][4] if hits else "none"}
-            for k, i, _sn, sc, _be in hits
+            {"kind": k, "id": i, "score": sc, "backend": be}
+            for k, i, _sn, sc, be in hits
         ]
🤖 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/context.py` around lines 155 - 159, The explain list comprehension
is using hits[0][4] instead of the per-item backend variable unpacked as _be;
change the list comprehension that builds result["explain"] to use the unpacked
backend (_be) for each hit (or rename _be to backend for clarity) so the entry
becomes {"kind": k, "id": i, "score": sc, "backend": _be} and no longer relies
on hits[0][4].
tests/test_context.py (1)

16-23: ⚡ Quick win

Avoid skipping unrelated context tests via autouse importorskip.

Right now, missing numpy skips the entire module, including non-embedding quality checks. Prefer a no-op fixture when numpy is absent, and skip only embedding-specific tests.

🔧 Minimal restructuring
 `@pytest.fixture`(autouse=True)
 def _mock_embedder() -> None:
-    pytest.importorskip("numpy")
-    from tests.embeddings._fakes import MockEmbedder
-    register(DEFAULT_MODEL_NAME, lambda: MockEmbedder(dim=8))
+    try:
+        import numpy  # noqa: F401
+    except Exception:
+        return
+    from tests.embeddings._fakes import MockEmbedder
+    register(DEFAULT_MODEL_NAME, lambda: MockEmbedder(dim=8))
 def test_build_context_pack_uses_semantic_default(tmp_path: Path) -> None:
+    pytest.importorskip("numpy")
     from tests.embeddings._fakes import MockEmbedder
     ...
 
 def test_build_context_pack_explain_flag_returns_score_breakdown(
     tmp_path: Path,
 ) -> None:
+    pytest.importorskip("numpy")
     from tests.embeddings._fakes import MockEmbedder
     ...
🤖 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 `@tests/test_context.py` around lines 16 - 23, The autouse fixture
_mock_embedder currently calls pytest.importorskip("numpy") which cancels the
whole module when numpy is missing; change it to conditionally import numpy
(try/except ImportError) inside the _mock_embedder fixture so that when numpy is
present you import tests.embeddings._fakes.MockEmbedder and call
register(DEFAULT_MODEL_NAME, lambda: MockEmbedder(dim=8)), and when numpy is
absent the fixture becomes a no-op (e.g., simply return/yield None) so unrelated
tests in the module are not skipped; keep references to the same fixture name
_mock_embedder, register, and MockEmbedder so the lookup in embedding tests
remains intact.
🤖 Prompt for all review comments with 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.

Inline comments:
In `@src/vouch/embeddings/migration.py`:
- Around line 38-80: The code currently wraps each entire backfill loop in a
try/except AttributeError which can hide per-item attribute issues or real bugs;
instead, call each iterator (store.list_claims, list_pages, list_sources,
list_entities, list_relations, list_evidence) without a broad AttributeError
catch and handle missing attributes per-item (use getattr(..., "") or check for
required attributes like id, text/title/body/name/description/locator/quote
before calling store._embed_and_store), or limit the try/except to only the call
that may not exist (e.g., around store.list_* to detect a missing method) and
catch/log exceptions from store._embed_and_store per item while still
incrementing touched only on success.

In `@src/vouch/jsonl_server.py`:
- Line 100: The import block in jsonl_server.py is unsorted (ruff I001) around
the line importing rrf_fuse; reorder the import statements to follow ruff/PEP8
grouping and alphabetical ordering (standard library, third-party, then
local/package imports) and adjust any # type: ignore comments to remain on the
specific import if still needed; specifically locate the line "from
.embeddings.fusion import rrf_fuse" and place it in the local/package imports
group in alphabetical order with the other local imports, mirroring the fix used
in server.py so the import block is consistently sorted and CI passes.

In `@src/vouch/server.py`:
- Line 127: The import of rrf_fuse from .embeddings.fusion is triggering ruff
I001 due to import sorting; move "from .embeddings.fusion import rrf_fuse" to
the top-level imports in server.py alongside the other imports so it follows
normal import ordering, or if you need it lazy, keep it as the sole statement in
its conditional block and append a "# noqa: I001" comment to silence the linter.

In `@tests/embeddings/test_dedup.py`:
- Around line 34-37: The autouse fixture _register_default mutates the global
embedding registry by calling register(DEFAULT_MODEL_NAME, _HashEmbedder)
without restoring prior state; update the fixture to save the previous
registration for DEFAULT_MODEL_NAME (or detect if none existed), yield to run
the test, and then restore the saved registration (or unregister if there was
none) after the test completes so subsequent tests run against the original
registry state; reference the fixture name _register_default, the register call,
DEFAULT_MODEL_NAME and _HashEmbedder when making the change.

In `@tests/embeddings/test_search.py`:
- Line 113: The current assertion permits fallback backends and should instead
confirm semantic-default embedding retrieval; update the assertion that checks
resp["result"][0]["backend"] to assert equality to "embedding" (and do the same
for the similar check at the other occurrence) so the test fails if results
regress to "fts5" or "substring"; locate the checks using the resp variable in
tests/embeddings/test_search.py and replace the in-tuple membership test with a
direct equality assertion against "embedding".

In `@tests/test_context.py`:
- Around line 67-81: The test test_build_context_pack_uses_semantic_default is
too weak—change it to assert that semantic retrieval actually wins and that
returned explain rows are consistently structured: after calling
build_context_pack(store, query="exact query string", limit=5) verify the
top-ranked item (pack["items"][0]) has id "c1" (not just present anywhere), and
assert each item in pack["items"] contains an "explain" dictionary with the
expected keys/shape (e.g., score/distance and method fields) and non-null
numeric score; use these checks around build_context_pack and pack.get("items")
to catch routing/regression issues.

---

Duplicate comments:
In `@src/vouch/embeddings/migration.py`:
- Around line 22-23: The check that currently returns None when adapters match
by name only (if current.name == stored) should be expanded to also verify model
version and embedding dimension; update the mismatch detection in migration
logic (the function handling adapter compatibility in
src/vouch/embeddings/migration.py) to compare current.version and current.dim
against the stored counterpart (e.g., stored.version and stored.dim or
stored["version"]/stored["dim"] depending on stored shape) and only return None
when name, version, and dim all match; if any of those differ, treat it as
incompatible so vectors will be backfilled.

In `@src/vouch/embeddings/scorer.py`:
- Around line 54-67: The code silently accepts unknown metric names in the
metrics iterable (used to build totals and drive calls to recall_at_k, mrr,
ndcg), so add validation at the start of the scoring routine in scorer.py:
define the allowed set {"recall@k","mrr","ndcg"}, compute unsupported =
set(metrics) - allowed, and if non-empty raise a ValueError listing the
unsupported names; then initialize totals only for supported metrics (e.g.
totals = {m:0.0 for m in metrics if m in allowed}) so you don't silently
accumulate zeros for misspelled/unsupported metrics.

---

Nitpick comments:
In `@src/vouch/context.py`:
- Around line 155-159: The explain list comprehension is using hits[0][4]
instead of the per-item backend variable unpacked as _be; change the list
comprehension that builds result["explain"] to use the unpacked backend (_be)
for each hit (or rename _be to backend for clarity) so the entry becomes
{"kind": k, "id": i, "score": sc, "backend": _be} and no longer relies on
hits[0][4].

In `@src/vouch/embeddings/hyde.py`:
- Around line 15-17: Normalize the query once before branching: compute a
stripped version of query (e.g., q = query.strip()) and use q for the length
check and for the return value so both the pass-through and templated paths
return the trimmed string; update the code that currently calls query.strip()
twice and returns the original query to instead use the single normalized
variable when returning directly or formatting DEFAULT_TEMPLATE, referencing the
existing DEFAULT_TEMPLATE and min_chars checks.

In `@tests/embeddings/test_migration.py`:
- Around line 54-55: The test currently asserts a count n >= 2 and validates
only index_db.get_embedding(store.kb_dir, kind="claim", id="c1"), which can miss
a failed backfill for the second claim; update the test to also assert that
index_db.get_embedding(store.kb_dir, kind="claim", id="c2") is not None (i.e.,
explicitly check both "c1" and "c2") so both inserted claims are verified as
re-encoded.

In `@tests/test_context.py`:
- Around line 16-23: The autouse fixture _mock_embedder currently calls
pytest.importorskip("numpy") which cancels the whole module when numpy is
missing; change it to conditionally import numpy (try/except ImportError) inside
the _mock_embedder fixture so that when numpy is present you import
tests.embeddings._fakes.MockEmbedder and call register(DEFAULT_MODEL_NAME,
lambda: MockEmbedder(dim=8)), and when numpy is absent the fixture becomes a
no-op (e.g., simply return/yield None) so unrelated tests in the module are not
skipped; keep references to the same fixture name _mock_embedder, register, and
MockEmbedder so the lookup in embedding tests remains intact.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 810772f4-d534-4bc5-b182-58eeb04f892f

📥 Commits

Reviewing files that changed from the base of the PR and between 28da722 and eb0a02c.

📒 Files selected for processing (23)
  • src/vouch/cli.py
  • src/vouch/context.py
  • src/vouch/embeddings/__init__.py
  • src/vouch/embeddings/cache.py
  • src/vouch/embeddings/dedup.py
  • src/vouch/embeddings/fusion.py
  • src/vouch/embeddings/hyde.py
  • src/vouch/embeddings/migration.py
  • src/vouch/embeddings/rerank.py
  • src/vouch/embeddings/scorer.py
  • src/vouch/index_db.py
  • src/vouch/jsonl_server.py
  • src/vouch/server.py
  • src/vouch/storage.py
  • tests/embeddings/test_dedup.py
  • tests/embeddings/test_fusion.py
  • tests/embeddings/test_hyde.py
  • tests/embeddings/test_migration.py
  • tests/embeddings/test_rerank.py
  • tests/embeddings/test_scorer.py
  • tests/embeddings/test_search.py
  • tests/embeddings/test_storage.py
  • tests/test_context.py

Comment thread src/vouch/embeddings/migration.py Outdated
Comment thread src/vouch/jsonl_server.py Outdated
Comment thread src/vouch/server.py Outdated
Comment on lines +34 to +37
@pytest.fixture(autouse=True)
def _register_default() -> None:
register(DEFAULT_MODEL_NAME, _HashEmbedder)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Restore registry state after the autouse fixture runs.

_register_default mutates shared global embedding registration on Line 36 without cleanup, so later tests may run with _HashEmbedder unintentionally. Please add teardown to restore the previous registration (or isolate via monkeypatch) to keep test order independence.

🤖 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 `@tests/embeddings/test_dedup.py` around lines 34 - 37, The autouse fixture
_register_default mutates the global embedding registry by calling
register(DEFAULT_MODEL_NAME, _HashEmbedder) without restoring prior state;
update the fixture to save the previous registration for DEFAULT_MODEL_NAME (or
detect if none existed), yield to run the test, and then restore the saved
registration (or unregister if there was none) after the test completes so
subsequent tests run against the original registry state; reference the fixture
name _register_default, the register call, DEFAULT_MODEL_NAME and _HashEmbedder
when making the change.

})
assert resp["ok"] is True
assert resp["result"]
assert resp["result"][0]["backend"] in ("embedding", "fts5", "substring")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Tighten backend assertions to actually validate semantic-default behavior.

These checks currently pass even if embedding retrieval silently regresses to fallback paths.

🔧 Proposed test tightening
-    assert resp["result"][0]["backend"] in ("embedding", "fts5", "substring")
+    assert resp["result"][0]["backend"] == "embedding"
...
-    assert result["backend"] in ("embedding", "fts5", "substring")
+    assert result["backend"] == "embedding"

Also applies to: 127-127

🤖 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 `@tests/embeddings/test_search.py` at line 113, The current assertion permits
fallback backends and should instead confirm semantic-default embedding
retrieval; update the assertion that checks resp["result"][0]["backend"] to
assert equality to "embedding" (and do the same for the similar check at the
other occurrence) so the test fails if results regress to "fts5" or "substring";
locate the checks using the resp variable in tests/embeddings/test_search.py and
replace the in-tuple membership test with a direct equality assertion against
"embedding".

Comment thread tests/test_context.py
Comment on lines +67 to +81
def test_build_context_pack_uses_semantic_default(tmp_path: Path) -> None:
from tests.embeddings._fakes import MockEmbedder
from vouch.context import build_context_pack
from vouch.embeddings import register
from vouch.embeddings.base import DEFAULT_MODEL_NAME
from vouch.models import Claim
from vouch.storage import KBStore

register(DEFAULT_MODEL_NAME, lambda: MockEmbedder(dim=8))
store = KBStore.init(tmp_path)
src = store.put_source(b"e")
store.put_claim(Claim(id="c1", text="exact query string", evidence=[src.id]))
pack = build_context_pack(store, query="exact query string", limit=5)
assert any(item["id"] == "c1" for item in pack.get("items", []))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Semantic/explain tests are too weak to catch routing regressions.

These tests currently check presence, not that semantic retrieval actually won or that explain rows are consistently structured.

🔧 Suggested assertion upgrades
-    pack = build_context_pack(store, query="exact query string", limit=5)
+    pack = build_context_pack(store, query="exact query string", limit=5, explain=True)
     assert any(item["id"] == "c1" for item in pack.get("items", []))
+    assert any(row.get("backend") == "embedding" for row in pack.get("explain", []))
...
     pack = build_context_pack(store, query="hello", limit=5, explain=True)
     assert "explain" in pack
-    assert any("backend" in row for row in pack["explain"])
+    assert pack["explain"]
+    assert all("backend" in row for row in pack["explain"])

Also applies to: 83-99

🤖 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 `@tests/test_context.py` around lines 67 - 81, The test
test_build_context_pack_uses_semantic_default is too weak—change it to assert
that semantic retrieval actually wins and that returned explain rows are
consistently structured: after calling build_context_pack(store, query="exact
query string", limit=5) verify the top-ranked item (pack["items"][0]) has id
"c1" (not just present anywhere), and assert each item in pack["items"] contains
an "explain" dictionary with the expected keys/shape (e.g., score/distance and
method fields) and non-null numeric score; use these checks around
build_context_pack and pack.get("items") to catch routing/regression issues.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

♻️ Duplicate comments (1)
src/vouch/embeddings/scorer.py (1)

54-67: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Validate metrics names before computing totals.

Line 54 accepts arbitrary metric keys, but Lines 62–67 only update known metrics. Unsupported names silently return 0.0, which can invalidate evaluation reports.

Suggested fix
 def evaluate(
@@
 ) -> dict[str, float]:
     """Run a metric sweep over a JSONL queries file."""
     from .. import index_db
+    supported = {"recall@k", "mrr", "ndcg"}
+    unknown = set(metrics) - supported
+    if unknown:
+        raise ValueError(f"Unsupported metrics: {sorted(unknown)}")
     totals = {m: 0.0 for m in metrics}
🤖 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/embeddings/scorer.py` around lines 54 - 67, The code builds totals
= {m: 0.0 for m in metrics} but only handles known metric keys ("recall@k",
"mrr", "ndcg"), so add validation of the metrics list before the loop: check the
provided metrics (variable metrics) against the supported set
{"recall@k","mrr","ndcg"} and either raise a ValueError listing unsupported
names or silently remove them (prefer raising for visibility); then proceed to
compute totals using recall_at_k, mrr, ndcg as already implemented (functions
recall_at_k, mrr, ndcg and variables hits, rel, k remain unchanged). Ensure the
error message names the offending metric(s) so callers can fix their input.
🧹 Nitpick comments (1)
tests/test_context.py (1)

16-24: ⚡ Quick win

Scope the numpy-dependent fixture to embedding-specific tests only.

Line 21 in an autouse=True fixture skips the entire module when numpy is missing, including non-embedding context tests. This reduces baseline coverage unnecessarily.

Suggested refactor
-@pytest.fixture(autouse=True)
-def _mock_embedder() -> None:
+@pytest.fixture
+def _mock_embedder() -> None:
@@
     register(DEFAULT_MODEL_NAME, lambda: MockEmbedder(dim=8))
-def test_build_context_pack_uses_semantic_default(tmp_path: Path) -> None:
+def test_build_context_pack_uses_semantic_default(
+    tmp_path: Path, _mock_embedder: None
+) -> None:
@@
-def test_build_context_pack_explain_flag_returns_score_breakdown(
-    tmp_path: Path,
+def test_build_context_pack_explain_flag_returns_score_breakdown(
+    tmp_path: Path, _mock_embedder: None
 ) -> None:
🤖 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 `@tests/test_context.py` around lines 16 - 24, The fixture _mock_embedder
currently uses autouse=True which causes the whole module to be skipped when
numpy is missing; change it so the numpy check and MockEmbedder registration
only run for tests that opt into embedding behavior: remove autouse=True from
the pytest.fixture decorator (make it a normal fixture, e.g., `@pytest.fixture`),
keep the pytest.importorskip("numpy") and the register(DEFAULT_MODEL_NAME,
lambda: MockEmbedder(dim=8)) lines, and update embedding tests to opt-in either
by adding `@pytest.mark.usefixtures`("_mock_embedder") or by requesting the
_mock_embedder fixture in their function signature so only those tests are
skipped when numpy is absent.
🤖 Prompt for all review comments with 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.

Duplicate comments:
In `@src/vouch/embeddings/scorer.py`:
- Around line 54-67: The code builds totals = {m: 0.0 for m in metrics} but only
handles known metric keys ("recall@k", "mrr", "ndcg"), so add validation of the
metrics list before the loop: check the provided metrics (variable metrics)
against the supported set {"recall@k","mrr","ndcg"} and either raise a
ValueError listing unsupported names or silently remove them (prefer raising for
visibility); then proceed to compute totals using recall_at_k, mrr, ndcg as
already implemented (functions recall_at_k, mrr, ndcg and variables hits, rel, k
remain unchanged). Ensure the error message names the offending metric(s) so
callers can fix their input.

---

Nitpick comments:
In `@tests/test_context.py`:
- Around line 16-24: The fixture _mock_embedder currently uses autouse=True
which causes the whole module to be skipped when numpy is missing; change it so
the numpy check and MockEmbedder registration only run for tests that opt into
embedding behavior: remove autouse=True from the pytest.fixture decorator (make
it a normal fixture, e.g., `@pytest.fixture`), keep the
pytest.importorskip("numpy") and the register(DEFAULT_MODEL_NAME, lambda:
MockEmbedder(dim=8)) lines, and update embedding tests to opt-in either by
adding `@pytest.mark.usefixtures`("_mock_embedder") or by requesting the
_mock_embedder fixture in their function signature so only those tests are
skipped when numpy is absent.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 7fde6adb-f79f-4eec-ae06-fce3c0feaee2

📥 Commits

Reviewing files that changed from the base of the PR and between eb0a02c and 35269ec.

📒 Files selected for processing (18)
  • src/vouch/cli.py
  • src/vouch/context.py
  • src/vouch/embeddings/base.py
  • src/vouch/embeddings/dedup.py
  • src/vouch/embeddings/fusion.py
  • src/vouch/embeddings/hyde.py
  • src/vouch/embeddings/migration.py
  • src/vouch/embeddings/rerank.py
  • src/vouch/embeddings/scorer.py
  • src/vouch/jsonl_server.py
  • src/vouch/server.py
  • tests/embeddings/test_dedup.py
  • tests/embeddings/test_fusion.py
  • tests/embeddings/test_hyde.py
  • tests/embeddings/test_migration.py
  • tests/embeddings/test_rerank.py
  • tests/embeddings/test_scorer.py
  • tests/test_context.py

@plind-junior

Copy link
Copy Markdown
Collaborator

Fix the conflict or close this PR in 12hr

@plind-junior

Copy link
Copy Markdown
Collaborator

Fix conflict

dripsmvcp and others added 4 commits May 25, 2026 20:41
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
mypy on Phase 7's _enrich_summary flagged `e.description[:200]` because
`Entity.description` is `str | None` and `None` isn't indexable. Same
fallback pattern as the page branch above: `(value or "")[:200]`.
Per CodeRabbit Critical on PR vouchdev#43: tests/test_context.py imports
`tests.embeddings._fakes.MockEmbedder` at module top, and _fakes.py
imports numpy. CI's base `[dev]` install lacks numpy, so test
collection of test_context.py fails before any test runs.

Move the MockEmbedder import inside the `_mock_embedder` autouse
fixture and guard with `pytest.importorskip("numpy")`. Tests that
depend on the fixture skip cleanly when numpy is unavailable;
existing context tests that don't actually need it (none currently,
since the fixture is autouse) still skip with a clear reason rather
than crash collection.
@dripsmvcp dripsmvcp force-pushed the feat/semantic-search-phase-7-context-pack branch from 35269ec to 1cf58c7 Compare May 25, 2026 11:42

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/vouch/server.py (1)

147-158: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Expose and forward explain in MCP kb_context.

The MCP tool signature omits explain, so callers can’t request score/backend breakdowns despite this PR adding that mode.

💡 Proposed fix
 def kb_context(
     task: str,
     limit: int = 10,
     max_chars: int | None = None,
     min_items: int = 0,
     require_citations: bool = False,
+    explain: bool = False,
 ) -> dict[str, Any]:
     """Build a ContextPack ready to inject into an agent prompt."""
     return build_context_pack(  # type: ignore[return-value]
         _store(), query=task, limit=limit, max_chars=max_chars,
         min_items=min_items, require_citations=require_citations,
+        explain=explain,
     )
🤖 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/server.py` around lines 147 - 158, Add an explain parameter to
kb_context and forward it to build_context_pack: update the kb_context signature
to include explain: bool = False (or the same type used by build_context_pack),
then pass explain=explain in the build_context_pack call so callers can request
score/backend breakdowns; ensure the function docstring and type hints reflect
the new parameter and keep the existing parameters (task, limit, max_chars,
min_items, require_citations) unchanged.
src/vouch/jsonl_server.py (1)

129-139: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Propagate explain in kb.context JSONL handler.

_h_context currently drops params.explain, so JSONL callers cannot activate explain mode even though this PR introduces it.

💡 Proposed fix
 def _h_context(p: dict) -> dict:
     return build_context_pack(  # type: ignore[return-value]
         _store(),
         query=p["task"],
         limit=int(p.get("limit", 10)),
         max_chars=p.get("max_chars"),
         min_items=int(p.get("min_items", 0)),
         require_citations=bool(p.get("require_citations", False)),
         fail_on_warnings=bool(p.get("fail_on_warnings", False)),
         fail_on_budget_truncation=bool(p.get("fail_on_budget_truncation", False)),
+        explain=bool(p.get("explain", False)),
     )
🤖 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/jsonl_server.py` around lines 129 - 139, _h_context drops the
incoming params.explain flag so callers cannot enable explain mode; update
_h_context to forward the explain flag into the call to build_context_pack
(i.e., pass explain=...) using the value from p (or p.get("params",
{}).get("explain") if the handler nests params), converting to a boolean as
needed so kb.context JSONL requests can activate explain mode; locate the call
to build_context_pack inside _h_context and add the explain parameter.
🤖 Prompt for all review comments with 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.

Outside diff comments:
In `@src/vouch/jsonl_server.py`:
- Around line 129-139: _h_context drops the incoming params.explain flag so
callers cannot enable explain mode; update _h_context to forward the explain
flag into the call to build_context_pack (i.e., pass explain=...) using the
value from p (or p.get("params", {}).get("explain") if the handler nests
params), converting to a boolean as needed so kb.context JSONL requests can
activate explain mode; locate the call to build_context_pack inside _h_context
and add the explain parameter.

In `@src/vouch/server.py`:
- Around line 147-158: Add an explain parameter to kb_context and forward it to
build_context_pack: update the kb_context signature to include explain: bool =
False (or the same type used by build_context_pack), then pass explain=explain
in the build_context_pack call so callers can request score/backend breakdowns;
ensure the function docstring and type hints reflect the new parameter and keep
the existing parameters (task, limit, max_chars, min_items, require_citations)
unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 314b2c4e-5ef2-499f-8510-3d5967e94ba1

📥 Commits

Reviewing files that changed from the base of the PR and between 35269ec and 1cf58c7.

📒 Files selected for processing (6)
  • src/vouch/cli.py
  • src/vouch/context.py
  • src/vouch/embeddings/base.py
  • src/vouch/jsonl_server.py
  • src/vouch/server.py
  • tests/test_context.py

@plind-junior plind-junior merged commit 9485ad8 into vouchdev:main May 25, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants