From d583b149076e359c362359863c57a9bd21ceab08 Mon Sep 17 00:00:00 2001 From: Steve-too Date: Tue, 7 Jul 2026 19:00:21 +0000 Subject: [PATCH] fix(cli): tolerate fts errors in search --- src/vouch/cli.py | 13 +++++++++--- tests/test_cli.py | 54 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 3 deletions(-) diff --git a/src/vouch/cli.py b/src/vouch/cli.py index c6b04ccf..423ba2e5 100644 --- a/src/vouch/cli.py +++ b/src/vouch/cli.py @@ -11,6 +11,7 @@ import io import json import os +import sqlite3 import sys from collections.abc import Iterator from contextlib import contextmanager, suppress @@ -2312,14 +2313,20 @@ def search( ) used = "embedding" if hits else used if not hits and backend in ("auto", "fts5"): - hits = index_db.search(store.kb_dir, q, limit=fetch_limit) - used = "fts5" if hits else used + try: + hits = index_db.search(store.kb_dir, q, limit=fetch_limit) + used = "fts5" if hits else used + except sqlite3.Error: + hits = [] if not hits and backend in ("auto", "substring"): hits = store.search_substring(q, limit=fetch_limit) used = "substring" if backend == "hybrid": emb = index_db.search_semantic(store.kb_dir, q, limit=fetch_limit * 2) - fts = index_db.search(store.kb_dir, q, limit=fetch_limit * 2) + try: + fts = index_db.search(store.kb_dir, q, limit=fetch_limit * 2) + except sqlite3.Error: + fts = [] hits = rrf_fuse(emb, fts, limit=fetch_limit) used = "hybrid" diff --git a/tests/test_cli.py b/tests/test_cli.py index 3d95c277..ca23f40c 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -12,6 +12,7 @@ import json import os +import sqlite3 import subprocess import sys from pathlib import Path @@ -294,6 +295,59 @@ def test_search_substring_backend_label(store: KBStore, monkeypatch: pytest.Monk assert "(substring)" in result.output +def test_search_auto_falls_back_when_fts_raises( + store: KBStore, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Auto search should mirror context/server resilience on FTS errors.""" + from vouch import index_db + from vouch.proposals import approve as do_approve + from vouch.proposals import propose_claim + + src = store.put_source(b"e") + pr = propose_claim(store, text="findable token", evidence=[src.id], proposed_by="agent") + do_approve(store, pr.id, approved_by="reviewer") + monkeypatch.setattr( + index_db, + "search", + lambda *a, **k: (_ for _ in ()).throw(sqlite3.Error("fts unavailable")), + ) + monkeypatch.setattr(index_db, "search_semantic", lambda *a, **k: []) + + result = CliRunner().invoke(cli, ["search", "findable"]) + + assert result.exit_code == 0, result.output + assert "(substring)" in result.output + + +def test_search_hybrid_keeps_semantic_hits_when_fts_raises( + store: KBStore, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Hybrid search should degrade to semantic-only when FTS5 is unavailable.""" + from vouch import index_db + from vouch.models import Claim + + src = store.put_source(b"e") + store.put_claim(Claim(id="c1", text="semantic token", evidence=[src.id])) + monkeypatch.setattr( + index_db, + "search_semantic", + lambda *a, **k: [("claim", "c1", "semantic token", 0.9)], + ) + monkeypatch.setattr( + index_db, + "search", + lambda *a, **k: (_ for _ in ()).throw(sqlite3.Error("fts unavailable")), + ) + + result = CliRunner().invoke(cli, ["search", "semantic", "--backend", "hybrid"]) + + assert result.exit_code == 0, result.output + assert "claim/c1" in result.output + assert "(hybrid)" in result.output + + def test_crystallize_cli_all_failures_exits_1(store: KBStore) -> None: with patch.object(KBStore, "_embed_and_store"): src = store.put_source(b"e")