"""Test dello stadio rerank: catena di fallback, cooldown, degrada con grazia.""" from __future__ import annotations import asyncio import json import httpx import pytest import rerank from test_api import auth_headers, make_record CHAIN = json.dumps( [ {"name": "primario", "url": "http://primario:9002", "key": "k1", "timeout_ms": 500}, {"name": "fallback", "url": "http://fallback:9003", "key": "k2", "timeout_ms": 5000}, ] ) @pytest.fixture(autouse=True) def _reset_chain(monkeypatch): rerank.reset_chain_cache() rerank._http = None yield rerank.reset_chain_cache() rerank._http = None def _use_chain(monkeypatch, raw=CHAIN): # rerank.py importa i valori di config con `from config import`: si patchano # gli attributi del modulo rerank, non config. monkeypatch.setattr(rerank, "RERANK_CHAIN", raw) rerank.reset_chain_cache() def _mock_client(handler) -> list[str]: """Client con transport mockato; ritorna la lista in cui registrare le chiamate.""" calls: list[str] = [] def tracking_handler(request): calls.append(f"{request.url.host}{request.url.path}") return handler(request) rerank._http = httpx.AsyncClient(transport=httpx.MockTransport(tracking_handler)) return calls # --------------------------------------------------------------------------- # parse della catena # --------------------------------------------------------------------------- def test_parse_chain_valida(monkeypatch): _use_chain(monkeypatch) nodes = rerank._get_chain() assert [n.name for n in nodes] == ["primario", "fallback"] assert nodes[0].key == "k1" assert nodes[0].timeout_ms == 500 assert nodes[1].timeout_ms == 5000 def test_parse_chain_json_invalido(monkeypatch): _use_chain(monkeypatch, raw="non-json") assert rerank._get_chain() == [] assert not rerank.enabled() def test_parse_chain_scarta_url_senza_schema(monkeypatch): _use_chain(monkeypatch, raw=json.dumps([{"name": "x", "url": "primario:9002"}])) assert rerank._get_chain() == [] # --------------------------------------------------------------------------- # fallback e cooldown # --------------------------------------------------------------------------- def test_fallback_primario_500(monkeypatch): _use_chain(monkeypatch) def handler(request): if request.url.host == "primario": return httpx.Response(500) return httpx.Response(200, json={"results": [{"index": 1, "relevance_score": 2.0}, {"index": 0, "relevance_score": -1.0}]}) _mock_client(handler) scores, backend, _took = asyncio.run(rerank.rerank("q", ["docA", "docB"])) assert backend == "fallback" # gli score tornano allineati alla posizione originaria dei documenti assert scores == pytest.approx([-1.0, 2.0]) def test_cooldown_salta_nodo_fallito(monkeypatch): _use_chain(monkeypatch) def handler(request): if request.url.host == "primario": return httpx.Response(500) return httpx.Response(200, json={"results": [{"index": 0, "relevance_score": 1.0}]}) _mock_client(handler) asyncio.run(rerank.rerank("q", ["a", "b"])) scores, backend, _ = asyncio.run(rerank.rerank("q", ["a", "b"])) assert backend == "fallback" # il primario รจ in cooldown e non viene richiamato def test_cooldown_non_blocca_per_sempre(monkeypatch): _use_chain(monkeypatch) calls: list[str] = [] def handler(request): calls.append(request.url.host) if request.url.host == "primario": return httpx.Response(500) return httpx.Response(200, json={"results": [{"index": 0, "relevance_score": 1.0}]}) _mock_client(handler) asyncio.run(rerank.rerank("q", ["a"])) # svuota il cooldown: il primario torna eleggibile rerank._down_until.clear() asyncio.run(rerank.rerank("q", ["a"])) assert calls == ["primario", "fallback", "primario", "fallback"] def test_tutti_nodi_falliti_restifica_none(monkeypatch): _use_chain(monkeypatch) _mock_client(lambda request: httpx.Response(500)) assert asyncio.run(rerank.rerank("q", ["a", "b"])) is None def test_enabled_richiede_catena(monkeypatch): _use_chain(monkeypatch, raw="") assert not rerank.enabled() _use_chain(monkeypatch) monkeypatch.setattr(rerank, "RERANK_ENABLED", True) assert rerank.enabled() monkeypatch.setattr(rerank, "RERANK_ENABLED", False) assert not rerank.enabled() def test_normalize_score(): assert rerank.normalize_score(0.0) == pytest.approx(0.5) assert rerank.normalize_score(10.0) > 0.99 assert rerank.normalize_score(-10.0) < 0.01 # --------------------------------------------------------------------------- # integrazione endpoint search # --------------------------------------------------------------------------- def test_search_rerank_riordina(client, monkeypatch): for text in ["alpha", "beta", "gamma"]: r = client.post("/v1/memories", json=make_record(text=text), headers=auth_headers()) assert r.status_code == 200 async def fake_rerank(query, docs): # inverte: beta (index 1) primo, poi gamma/alpha return [0.1, 0.9, 0.5], "finto", 12 monkeypatch.setattr(rerank, "enabled", lambda: True) monkeypatch.setattr(rerank, "rerank", fake_rerank) resp = client.post("/v1/memories:search", json={"query": "q", "top_k": 3}, headers=auth_headers()) assert resp.status_code == 200 data = resp.json() assert data["rerank"]["used"] is True assert data["rerank"]["backend"] == "finto" texts = [r["text"] for r in data["results"]] assert texts == ["beta", "gamma", "alpha"] assert data["results"][0]["rerank_score"] == pytest.approx(rerank.normalize_score(0.9), abs=0.01) def test_search_rerank_disattivato_per_query(client, monkeypatch): for text in ["alpha", "beta"]: client.post("/v1/memories", json=make_record(text=text), headers=auth_headers()) async def fail_rerank(query, docs): raise AssertionError("rerank non deve essere chiamato con rerank=false") monkeypatch.setattr(rerank, "enabled", lambda: True) monkeypatch.setattr(rerank, "rerank", fail_rerank) resp = client.post("/v1/memories:search", json={"query": "q", "top_k": 2, "rerank": False}, headers=auth_headers()) assert resp.status_code == 200 assert resp.json()["rerank"]["enabled"] is False def test_search_rerank_fallito_degrada_con_grazia(client, monkeypatch): for text in ["alpha", "beta"]: client.post("/v1/memories", json=make_record(text=text), headers=auth_headers()) async def fail_rerank(query, docs): return None monkeypatch.setattr(rerank, "enabled", lambda: True) monkeypatch.setattr(rerank, "rerank", fail_rerank) resp = client.post("/v1/memories:search", json={"query": "q", "top_k": 2}, headers=auth_headers()) assert resp.status_code == 200 data = resp.json() assert data["rerank"]["used"] is False assert "non raggiungibili" in data["rerank"]["reason"] assert len(data["results"]) == 2 # ordine di fusione preservato