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:
@@ -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"]
|
||||
@@ -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,
|
||||
}]
|
||||
Reference in New Issue
Block a user