"""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_generation_filter_is_sent_exactly_and_legacy_404_fails_closed(monkeypatch): calls = [] def fake_call(self, function, payload): calls.append(payload) raise VectorRestError("HTTP 404 missing filtered RPC") monkeypatch.setattr(VectorRestClient, "_call", fake_call) metadata_filter = {"vector_generation": "gen:abc", "document_ids": ["doc:1"]} with pytest.raises(VectorRestError, match="404"): _client().search_similar( "evidence", [0.1] * 4, 5, kinds=["evidence"], metadata_filter=metadata_filter ) assert calls == [{ "query_embedding": [0.1] * 4, "limit_count": 5, "table_name": "evidence", "kinds": ["evidence"], "metadata_filter": metadata_filter, }] 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"]