feat(vector): server-side kinds filter for search_similar (legacy fallback) + graceful solved-search degrade

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-07 14:23:28 +02:00
co-authored by Claude Fable 5
parent 8d427a2ccb
commit a26f16ad79
7 changed files with 340 additions and 23 deletions
+104
View File
@@ -0,0 +1,104 @@
"""L1: filtro `kinds` server-side su search_similar (fast-follow post active-memory).
`memory` e `solved_question` condividono la tabella pgvector: senza filtro nel
`WHERE` della RPC, il top-k della tabella mista puo' affamare la ricerca memorie
(e viceversa) perche' il filtro per kind avveniva solo client-side DOPO il taglio
a top_n. Questi test fissano il contratto client:
- il client manda `kinds` nel payload della RPC quando richiesto (filtro esatto);
- su un server legacy (funzione a 3 argomenti -> PostgREST 404) ritenta senza
`kinds`, lasciando il filtro al post-filter client-side esistente;
- RestSearcher inoltra i kinds alla RPC.
"""
import pytest
from tht.config import RestConfig
from tht.vectorstore.reader import RestSearcher
from tht.vectorstore.rest_client import VectorRestClient, VectorRestError
class _Resp:
def __init__(self, status_code=200, payload=None, text=""):
self.status_code = status_code
self._payload = [] if payload is None else payload
self.text = text or ("[]" if status_code == 200 else text)
@property
def ok(self):
return self.status_code < 400
def json(self):
if not self.ok:
return {"message": self.text}
return self._payload
def _client() -> VectorRestClient:
return VectorRestClient(RestConfig(base_url="https://v/", api_key="K-READ"))
def test_search_similar_sends_kinds_in_rpc_payload(monkeypatch):
seen = []
def fake_post(url, json=None, **kw):
seen.append(json)
return _Resp(payload=[{"similarity": 0.9, "metadata": {"kind": "memory"}}])
monkeypatch.setattr("tht.vectorstore.rest_client.requests.post", fake_post)
rows = _client().search_similar("memory", [0.1] * 4, 5, kinds=["memory"])
assert len(rows) == 1
assert seen[0]["kinds"] == ["memory"]
assert seen[0]["table_name"] == "memory"
assert seen[0]["limit_count"] == 5
def test_search_similar_omits_kinds_when_none(monkeypatch):
seen = []
def fake_post(url, json=None, **kw):
seen.append(json)
return _Resp()
monkeypatch.setattr("tht.vectorstore.rest_client.requests.post", fake_post)
_client().search_similar("memory", [0.1] * 4, 5)
assert "kinds" not in seen[0]
def test_search_similar_falls_back_without_kinds_on_legacy_404(monkeypatch):
# Server legacy: la funzione a 4 argomenti non esiste -> PostgREST 404 (PGRST202).
# Il client ritenta senza `kinds`; il filtro resta al post-filter client-side.
seen = []
def fake_post(url, json=None, **kw):
seen.append(json)
if "kinds" in json:
return _Resp(status_code=404, text="Could not find the function (PGRST202)")
return _Resp(payload=[{"similarity": 0.8, "metadata": {"kind": "memory"}}])
monkeypatch.setattr("tht.vectorstore.rest_client.requests.post", fake_post)
rows = _client().search_similar("memory", [0.1] * 4, 5, kinds=["memory"])
assert len(rows) == 1
assert len(seen) == 2
assert "kinds" in seen[0] and "kinds" not in seen[1]
def test_search_similar_reraises_non_404_with_kinds(monkeypatch):
def fake_post(url, json=None, **kw):
return _Resp(status_code=500, text="boom")
monkeypatch.setattr("tht.vectorstore.rest_client.requests.post", fake_post)
with pytest.raises(VectorRestError, match="HTTP 500"):
_client().search_similar("memory", [0.1] * 4, 5, kinds=["memory"])
def test_rest_searcher_forwards_kinds_to_client():
calls = []
class FakeClient:
def search_similar(self, table_name, query_vec, top_n, kinds=None):
calls.append((table_name, top_n, kinds))
return [{"similarity": 0.7, "metadata": {"kind": "solved_question",
"record_key": "solved:s1"}}]
hits = RestSearcher(FakeClient()).search([0.1] * 4, top_n=3, kinds=["solved_question"])
assert calls == [("memory", 3, ["solved_question"])]
assert [h.kind for h in hits] == ["solved_question"]
+85
View File
@@ -0,0 +1,85 @@
"""L1: `tht memory solved-search` — degrado gentile e mapping dei risultati.
SKILL.md prescrive solved-search in F4/F6/F7 di OGNI sessione: a vectordb
irraggiungibile (VPN giu', Ollama spento) il comando non deve morire con un
traceback grezzo ma degradare a un avviso di una riga su stderr, con stdout
puro (`[]` in modalita' --json) ed exit 0, cosi' il modello prosegue senza
exemplar. Il finalize-hook gestisce gia' lo stesso scenario in modo analogo.
"""
import json
from typer.testing import CliRunner
from tht.cli import app
from tht.vectorstore.rest_client import VectorRestError
from tht.vectorstore.store import VectorHit
def _cfg(tmp_path):
cfg = tmp_path / "workspace.yaml"
cfg.write_text(
"database: {database: d, schema: s, user: u, password: p, transport: direct}\n"
f"paths: {{artifacts: {tmp_path/'a'}, indexes: {tmp_path/'i'}, sessions: {tmp_path/'se'}}}\n"
"vector_db: {database: v, schema: vectors, user: u, password: p, transport: direct}\n"
"embeddings: {base_url: 'http://localhost:11434', model: nomic-embed-text, dim: 8}\n"
)
return cfg
def test_solved_search_degrades_when_vectordb_unreachable(tmp_path, monkeypatch):
def boom(cfg):
raise VectorRestError("Vector REST non raggiungibile su https://v/ (rpc search_similar)")
monkeypatch.setattr("tht.cli.vector_cmd.open_searcher", boom)
res = CliRunner().invoke(
app, ["memory", "solved-search", "quante ablazioni", "--json", "-c", str(_cfg(tmp_path))]
)
assert res.exit_code == 0, res.output
assert json.loads(res.stdout) == [] # stdout puro: JSON valido
assert "exemplar non disponibili" in res.stderr
def test_solved_search_degrades_human_mode(tmp_path, monkeypatch):
def boom(cfg):
raise VectorRestError("Vector REST non raggiungibile")
monkeypatch.setattr("tht.cli.vector_cmd.open_searcher", boom)
res = CliRunner().invoke(
app, ["memory", "solved-search", "quante ablazioni", "-c", str(_cfg(tmp_path))]
)
assert res.exit_code == 0, res.output
assert "exemplar non disponibili" in res.stderr
assert "Traceback" not in res.stderr
def test_solved_search_json_maps_hit_metadata(tmp_path, monkeypatch):
hit = VectorHit(
id="solved:s1", kind="solved_question", ref="s1", title="quante ablazioni nel 2023",
content="quante ablazioni nel 2023",
metadata={
"session_id": "s1", "question": "quante ablazioni nel 2023",
"sql": "SELECT 1", "tables": ["fact_seeablazione"],
},
similarity=0.91,
)
class FakeSearcher:
def search(self, vec, top_n=10, kinds=None):
assert kinds == ["solved_question"]
return [hit]
class FakeEmbedder:
def embed_query(self, text):
return [0.1] * 8
monkeypatch.setattr("tht.cli.vector_cmd.open_searcher", lambda cfg: FakeSearcher())
monkeypatch.setattr("tht.cli.vector_cmd.make_embedder", lambda e: FakeEmbedder())
res = CliRunner().invoke(
app, ["memory", "solved-search", "quante ablazioni", "--json", "-c", str(_cfg(tmp_path))]
)
assert res.exit_code == 0, res.output
data = json.loads(res.stdout)
assert data == [{
"session_id": "s1", "question": "quante ablazioni nel 2023",
"sql": "SELECT 1", "tables": ["fact_seeablazione"], "score": 0.91,
}]