chore(gateway): rimuove la copia duplicata del gateway dal package (dedup)

La cartella gateway/ era un duplicato byte-identico (sha256 su 14 file) del
repository canonico privato enne2/qmem-gateway (GATEWAY_VERSION 2.11.0,
guardrail similarity-v2): due fonti dello stesso codice, rischio di divergenza.

- la fonte unica del gateway + deploy (docker-compose.yml, .env, test, README
  operativo) è git:git.enne2.net/enne2/qmem-gateway, clonata in ~/dev/qmem-gateway
- gli artefatti presenti SOLO qui (README.md, requirements-dev.txt, tests/)
  sono stati spostati in quella repo prima della rimozione (commit locale
  15cc9d3, nessun push): nessuna perdita di contenuto
- backup integrale della cartella rimossa:
  ~/archive/backups/pi-qmem-gateway-copy-20260913-173728.tar.gz
- README aggiornato con il puntatore al repo canonico

Il package pi-qmem ora contiene solo estensione, skill, tool CLI e test
dell'indice locale (il gateway si deploya dal repo dedicato).
This commit is contained in:
Matteo Benedetto
2026-09-13 17:37:45 +02:00
parent 322b4cf446
commit d509778448
23 changed files with 15 additions and 2925 deletions
+15 -3
View File
@@ -116,9 +116,21 @@ Le regole vincolanti (obbligo `project_id`, punteggi, correzione/supersede, disc
## Gateway (componente server)
La cartella `gateway/` contiene il Memory Gateway FastAPI da deployare sul
server (Docker Compose con Qdrant 1.19 + Ollama BGE-M3). Vedi
`gateway/README.md` per il deploy.
Il Memory Gateway FastAPI + Qdrant **non è più duplicato in questo package**:
la fonte unica è il repository dedicato
```
git:git.enne2.net/enne2/qmem-gateway (privato)
```
che contiene il codice (`gateway/`), il deploy (`docker-compose.yml` con Qdrant
1.19 + gateway, `.env`, `.gitignore`), la suite di test e il README operativo.
Su questa macchina è clonato in `~/dev/qmem-gateway`.
Motivo della dedup: la copia qui dentro era byte-identica al repo canonico
(GATEWAY_VERSION 2.11.0 / guardrail similarity-v2) e manteneva due fonti
potenzialmente divergenti. Storia completa del codice rimossa:
`git log -- gateway/` (ultimo commit prima della rimozione).
## Architettura
-23
View File
@@ -1,23 +0,0 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Hash del commit Git da cui è costruita l'immagine (iniettato al build:
# docker compose build --build-arg GIT_COMMIT=$(git rev-parse HEAD) gateway
# o come args nel compose). Esposto da GET /v1/version e /v1/status.
ARG GIT_COMMIT=unknown
ENV GIT_COMMIT=$GIT_COMMIT
COPY . .
# Utente non-root con privilegi minimi (best practice container)
RUN useradd --create-home --uid 10001 appuser
USER appuser
# Pre-download del modello sparso BM25 (cache in /home/appuser/.cache/fastembed)
RUN python -c "from fastembed import SparseTextEmbedding; SparseTextEmbedding(model_name='Qdrant/bm25')"
EXPOSE 8080
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080"]
-90
View File
@@ -1,90 +0,0 @@
# Memory Gateway — deploy
Componente server di **pi-qmem**: FastAPI + Qdrant 1.19 + Ollama (BGE-M3).
Nessun LLM in scrittura: l'agente salva record deliberati e strutturati.
```
pi (estensione pi-qmem) ──HTTPS/VPN──▶ Memory Gateway (FastAPI:8082) ──▶ Qdrant 1.19 (6333)
└──▶ Ollama BGE-M3 (11434, nativo host)
```
## Deploy (Docker Compose)
```bash
# 1. Prepara l'ambiente (vedi qmem-gateway/docker-compose.yml come riferimento)
cp .env.example .env
chmod 600 .env
# genera le chiavi:
# QDRANT_ADMIN_API_KEY=$(openssl rand -hex 32)
# QDRANT_READ_ONLY_API_KEY=$(openssl rand -hex 32)
# API_KEYS=$(openssl rand -hex 32) # chiave condivisa per gli agenti
# 2. Avvia
docker compose up -d --build
# 3. Verifica
curl http://127.0.0.1:8082/v1/status
```
Requisiti: Docker + Compose v2, Ollama con modello `bge-m3` sul host
(`ollama pull bge-m3`), porta 8082 libera sull'interfaccia VPN.
## API
| Endpoint | Descrizione |
|---|---|
| `POST /v1/memories` | Crea record (text, kind, agent_id, scope, **project_id obbligatorio**, source, expires_at, supersedes_id, supersede_reason). Applica il guardrail di similarità pre-scrittura |
| `POST /v1/memories:search` | Ricerca semantica (query, kind, project_id, scope, top_k, include_superseded, min_score) |
| `GET /v1/memories/{id}` | Recupera per UUID |
| `DELETE /v1/memories/{id}` | Elimina per UUID |
| `GET /v1/meta/overview` | Discovery: scope×kind, progetti, agenti, superseduti (cache 60s) |
| `GET /v1/status` | Health + statistiche |
Auth: header `X-API-Key` (chiave condivisa, accesso completo). Rate limit 120 req/min per chiave. Audit log in JSON lines (docker logs).
## Versione del codice
`GET /v1/version` (pubblico) espone la versione del codice in esecuzione, inclusa l'hash del commit Git da cui è stato costruito il container:
```json
{"version": "2.7.0", "git_commit": "eccb2cb...", "guardrail_version": "similarity-v1", ...}
```
Anche `GET /v1/status` include `version`, `git_commit` e `guardrail_version`. L'hash è iniettato al build via `ARG GIT_COMMIT`/`ENV GIT_COMMIT` nel Dockerfile (default `unknown`). Per costruire con l'hash:
```bash
docker compose build --build-arg GIT_COMMIT=$(git rev-parse HEAD) gateway
# o nel compose: build: { context: ./gateway, args: { GIT_COMMIT: ${GIT_COMMIT:-unknown} } }
```
## Guardrail di similarità (v1)
Enforcement deterministico FUORI dall'LLM, prima di ogni scrittura su `POST /v1/memories`:
1. **Strato 1 — hash esatto**: SHA-256 del testo normalizzato (`text_hash` nel payload). Se esiste un record attivo con lo stesso hash → `409 BLOCK (EXACT_DUPLICATE)`.
2. **Strato 2 — similarità semantica top-3**: embedding BGE-M3 cosine sui record attivi (esclusi i superseded).
- top-1 ≥ `GUARDRAIL_BLOCK_THRESHOLD` (default 0.85) → `409 BLOCK (KNOWN_SOLUTION)`
- top-1 ≥ `GUARDRAIL_WARN_THRESHOLD` (default 0.70) → `WARN`: salva con flag `guardrail` nel payload
- altrimenti → `ALLOW`
Il **supersede esplicito** (`supersedes_id`) è una correzione intenzionale: bypassa il guardrail.
Configurazione (env): `GUARDRAIL_ENABLED` (default true), `GUARDRAIL_BLOCK_THRESHOLD`, `GUARDRAIL_WARN_THRESHOLD`. Soglie di partenza da calibrare sul corpus reale.
Risposta BLOCK (409):
```json
{"detail": {"error": "duplicate_memory", "reason": "KNOWN_SOLUTION", "matches": [{"memory_id": "...", "score": 0.92}], "message": "..."}}
```
## Sicurezza
- Qdrant bindato su 127.0.0.1; gateway solo su interfaccia VPN
- Chiavi in `.env` (0600), mai committate
- JWT RBAC su Qdrant (admin + read-only)
- Backup: snapshot Qdrant + rotazione 7 giorni (cron: `0 3 * * * /opt/memory/backup.sh`)
## Dettagli operativi
Procedure complete (teardown, restore, nginx, troubleshooting): vedi
`docs/playbook.md` nel repo pi-qmem.
-38
View File
@@ -1,38 +0,0 @@
"""Audit e autenticazione del gateway."""
from __future__ import annotations
import time
from datetime import datetime, timezone
from typing import Any
from fastapi import Header, HTTPException
import config
import state
def require_auth(x_api_key: str = Header(...)) -> str:
if x_api_key not in config.API_KEYS:
raise HTTPException(status_code=401, detail="API key non valida")
now = time.monotonic()
window = state.ratelimit.setdefault(x_api_key, [])
window[:] = [t for t in window if now - t < 60]
if len(window) >= config.RATE_LIMIT_PER_MIN:
raise HTTPException(status_code=429, detail="Rate limit superato")
window.append(now)
return x_api_key
def audit(key: str, action: str, **extra: Any) -> None:
entry = {
"ts": datetime.now(timezone.utc).isoformat(),
"key": key[:8] + "...",
"action": action,
"request_id": state.request_id.get(),
**extra,
}
config.log.info(__import__("json").dumps(entry, default=str))
def now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
-28
View File
@@ -1,28 +0,0 @@
"""Pulizia periodica dei record scaduti."""
from __future__ import annotations
import time
from typing import Any, Callable
from qdrant_client.http import models as qm
from config import log
async def loop(qdrant: Any, collection: str, invalidate_meta: Callable[[], None]) -> None:
while True:
try:
scroll = qdrant.scroll(
collection_name=collection,
scroll_filter=qm.Filter(must=[qm.FieldCondition(key="expires_at", range=qm.Range(lt=time.time()))]),
limit=100,
with_payload=False,
)
ids = [point.id for point in scroll[0]]
if ids:
qdrant.delete(collection_name=collection, points_selector=ids)
invalidate_meta()
log.info("cleanup: rimossi %d record scaduti", len(ids))
except Exception as exc: # noqa: BLE001
log.warning("cleanup error: %s", exc)
await __import__("asyncio").sleep(3600)
-78
View File
@@ -1,78 +0,0 @@
"""Configurazione statica del Memory Gateway letta dall'ambiente."""
from __future__ import annotations
import logging
import os
from collections import Counter
from typing import Any
QDRANT_URL = os.environ.get("QDRANT_URL", "http://127.0.0.1:6333")
QDRANT_API_KEY = os.environ.get("QDRANT_API_KEY", "")
EMBED_API = os.environ.get("EMBED_API", "ollama")
EMBED_URL = os.environ.get("EMBED_URL", os.environ.get("OLLAMA_URL", "http://127.0.0.1:11434"))
EMBED_MODEL = os.environ.get("EMBED_MODEL", "bge-m3")
EMBED_API_KEY = os.environ.get("EMBED_API_KEY", "")
EMBED_DIM = int(os.environ.get("EMBED_DIM", "1024"))
# Catena di fallback per gli embedding (JSON, formato RERANK_CHAIN + campo "api").
# Vuota → comportamento legacy: endpoint singolo da EMBED_API/EMBED_URL/EMBED_API_KEY.
EMBED_CHAIN = os.environ.get("EMBED_CHAIN", "")
EMBED_TIMEOUT_MS = int(os.environ.get("EMBED_TIMEOUT_MS", "30000"))
EMBED_RETRY_COOLDOWN_S = int(os.environ.get("EMBED_RETRY_COOLDOWN_S", "60"))
# Retry transiente per le chiamate Qdrant (store/search inclusi)
QDRANT_RETRIES = int(os.environ.get("QDRANT_RETRIES", "3"))
COLLECTION = os.environ.get("COLLECTION", "memories")
API_KEYS: set[str] = {k.strip() for k in os.environ.get("API_KEYS", "").split(",") if k.strip()}
RATE_LIMIT_PER_MIN = int(os.environ.get("RATE_LIMIT_PER_MIN", "120"))
MAX_TEXT_LEN = int(os.environ.get("MAX_TEXT_LEN", "8000"))
GUARDRAIL_ENABLED = os.environ.get("GUARDRAIL_ENABLED", "true").lower() == "true"
GUARDRAIL_BLOCK_THRESHOLD = float(os.environ.get("GUARDRAIL_BLOCK_THRESHOLD", "0.85"))
GUARDRAIL_WARN_THRESHOLD = float(os.environ.get("GUARDRAIL_WARN_THRESHOLD", "0.70"))
GUARDRAIL_VERSION = "similarity-v2"
# Strato 3 del guardrail: cross-encoder (richiede catena rerank attiva)
GUARDRAIL_RERANK = os.environ.get("GUARDRAIL_RERANK", "false").lower() == "true"
GUARDRAIL_RERANK_BLOCK = float(os.environ.get("GUARDRAIL_RERANK_BLOCK", "0.90"))
GUARDRAIL_RERANK_SUGGEST = float(os.environ.get("GUARDRAIL_RERANK_SUGGEST", "0.85"))
# Verifica supersede: cross-score (nuovo, vecchio) sotto soglia → warning non bloccante
GUARDRAIL_SUPERSEDE_CHECK = os.environ.get("GUARDRAIL_SUPERSEDE_CHECK", "false").lower() == "true"
GUARDRAIL_SUPERSEDE_MIN = float(os.environ.get("GUARDRAIL_SUPERSEDE_MIN", "0.50"))
# Score composito: rerank + importance + recency + authority (post-rerank)
SCORE_W_RELEVANCE = float(os.environ.get("SCORE_W_RELEVANCE", "0.55"))
SCORE_W_IMPORTANCE = float(os.environ.get("SCORE_W_IMPORTANCE", "0.20"))
SCORE_W_RECENCY = float(os.environ.get("SCORE_W_RECENCY", "0.15"))
SCORE_W_AUTHORITY = float(os.environ.get("SCORE_W_AUTHORITY", "0.10"))
SCORE_DECAY_HALF_LIFE_DAYS = float(os.environ.get("SCORE_DECAY_HALF_LIFE_DAYS", "180"))
GIT_COMMIT = os.environ.get("GIT_COMMIT", "unknown").strip()
GATEWAY_VERSION = os.environ.get("GATEWAY_VERSION", "2.11.0").strip()
VM_PUSH_URL = os.environ.get("VM_PUSH_URL", "http://host.docker.internal:8428/api/v1/import/prometheus")
VM_PUSH_INTERVAL = int(os.environ.get("VM_PUSH_INTERVAL", "30"))
METRICS_ENABLED = os.environ.get("METRICS_ENABLED", "true").lower() == "true"
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("memory-gateway")
SPARSE_VECTOR_NAME = "bm25"
# Re-ranking: catena di fallback resiliente (frigate → brain locale).
# Il default nel codice è OFF; il deploy imposta RERANK_ENABLED=true e la catena.
RERANK_ENABLED = os.environ.get("RERANK_ENABLED", "false").lower() == "true"
RERANK_MODEL = os.environ.get("RERANK_MODEL", "bge-reranker-v2-m3")
RERANK_CANDIDATES = int(os.environ.get("RERANK_CANDIDATES", "16"))
RERANK_MAX_DOC_CHARS = int(os.environ.get("RERANK_MAX_DOC_CHARS", "800"))
RERANK_TIMEOUT_MS = int(os.environ.get("RERANK_TIMEOUT_MS", "10000"))
RERANK_RETRY_COOLDOWN_S = int(os.environ.get("RERANK_RETRY_COOLDOWN_S", "60"))
RERANK_CHAIN = os.environ.get("RERANK_CHAIN", "")
_metrics: dict[str, Any] = {
"requests": Counter(),
"duration_sum": Counter(),
"duration_count": Counter(),
"errors": Counter(),
"search_queries": 0,
"search_hits": 0,
"rerank_calls": Counter(),
"rerank_duration_sum": Counter(),
"embed_calls": Counter(),
"embed_duration_sum": Counter(),
"qdrant_retries": 0,
}
-222
View File
@@ -1,222 +0,0 @@
"""Embedding denso (Ollama/llama.cpp) e sparse BM25, con catena di fallback resiliente.
Catena da EMBED_CHAIN (JSON, stesso formato di RERANK_CHAIN + campo "api"):
il primo nodo raggiungibile vince, i nodi falliti entrano in cooldown. Se
EMBED_CHAIN è vuota si usa il comportamento legacy (endpoint singolo da
EMBED_API/EMBED_URL/EMBED_API_KEY). L'ultimo errore viene rilanciato al client
come gli endpoint precedenti: nessuna degradazione silenziosa della scrittura.
"""
from __future__ import annotations
import json
import time
from dataclasses import dataclass
from typing import Any, Optional
import httpx
from qdrant_client.http import models as qm
import metrics
from config import (
EMBED_API,
EMBED_API_KEY,
EMBED_CHAIN,
EMBED_DIM,
EMBED_MODEL,
EMBED_RETRY_COOLDOWN_S,
EMBED_TIMEOUT_MS,
EMBED_URL,
SPARSE_VECTOR_NAME,
log,
)
try:
from fastembed import SparseTextEmbedding
_sparse_model: Optional[Any] = None
SPARSE_AVAILABLE = True
except Exception: # noqa: BLE001
_sparse_model = None
SPARSE_AVAILABLE = False
log.warning("fastembed non disponibile: hybrid retrieval disattivato")
_http: Optional[httpx.AsyncClient] = None
@dataclass(frozen=True)
class EmbedNode:
"""Un endpoint embedding nella catena di fallback."""
name: str
url: str
api: str # "llamacpp" (/v1/embeddings) | "ollama" (/api/embed)
key: str
timeout_ms: int
def parse_chain(raw: str, default_api: str, default_url: str, default_key: str) -> list[EmbedNode]:
"""Parsa EMBED_CHAIN (JSON); vuota o invalida → endpoint legacy singolo."""
nodes: list[EmbedNode] = []
if raw:
try:
entries = json.loads(raw)
for entry in entries if isinstance(entries, list) else []:
if not isinstance(entry, dict) or not entry.get("url"):
continue
url = str(entry["url"]).rstrip("/")
api = str(entry.get("api") or "llamacpp")
if api not in ("llamacpp", "ollama") or not url.startswith(("http://", "https://")):
continue
nodes.append(
EmbedNode(
name=str(entry.get("name") or url),
url=url,
api=api,
key=str(entry.get("key") or ""),
timeout_ms=int(entry.get("timeout_ms", EMBED_TIMEOUT_MS)),
)
)
except (json.JSONDecodeError, TypeError, ValueError):
log.error("EMBED_CHAIN non è JSON valido: uso l'endpoint legacy")
if not nodes and default_url:
# Compatibilità legacy: endpoint singolo dagli env EMBED_*
nodes = [EmbedNode(name="embed", url=default_url.rstrip("/"), api=default_api, key=default_key, timeout_ms=EMBED_TIMEOUT_MS)]
return nodes
_chain: Optional[list[EmbedNode]] = None
_down_until: dict[str, float] = {} # url → monotonic deadline del cooldown
_http: Optional[httpx.AsyncClient] = None
def _get_chain() -> list[EmbedNode]:
global _chain
if _chain is None:
_chain = parse_chain(EMBED_CHAIN, EMBED_API, EMBED_URL, EMBED_API_KEY)
return _chain
def reset_chain_cache() -> None:
"""Forza il re-parse della catena (usato dai test)."""
global _chain
_chain = None
_down_until.clear()
def get_http() -> httpx.AsyncClient:
global _http
if _http is None:
_http = httpx.AsyncClient(timeout=30)
return _http
async def close_http() -> None:
global _http
if _http is not None:
await _http.aclose()
_http = None
def chain_nodes() -> list[EmbedNode]:
return _get_chain()
async def embed(text: str) -> list[float]:
"""Embedding con catena di fallback: ritorna il vettore o rilancia dopo l'ultimo fallimento."""
chain = _get_chain()
if not chain:
raise RuntimeError("nessun endpoint embedding configurato")
now = time.monotonic()
live = [n for n in chain if _down_until.get(n.url, 0) <= now]
if not live:
# tutti in cooldown: ritenta comunque il primo (meglio di un fallimento immediato)
live = [chain[0]]
payload = {"model": EMBED_MODEL, "input": text}
started = time.monotonic()
last_exc: Optional[Exception] = None
for node in live:
headers = {"Content-Type": "application/json"}
if node.key:
headers["Authorization"] = f"Bearer {node.key}"
path = "/v1/embeddings" if node.api == "llamacpp" else "/api/embed"
try:
t0 = time.monotonic()
response = await get_http().post(
f"{node.url}{path}",
json=payload,
headers=headers,
timeout=httpx.Timeout(node.timeout_ms / 1000.0),
)
response.raise_for_status()
data = response.json()
vector = data["data"][0]["embedding"] if node.api == "llamacpp" else data["embeddings"][0]
if len(vector) != EMBED_DIM:
raise ValueError(f"dimensione vettore {len(vector)} != EMBED_DIM {EMBED_DIM}")
took = int((time.monotonic() - started) * 1000)
metrics.record_embed(node.name, True, took)
return vector
except (httpx.HTTPError, ValueError, KeyError, IndexError, TypeError) as exc:
took = int((time.monotonic() - t0) * 1000)
_down_until[node.url] = time.monotonic() + EMBED_RETRY_COOLDOWN_S
last_exc = exc
metrics.record_embed(node.name, False, took)
log.warning(
"embed: nodo '%s' fallito dopo %dms (%s: %s) → cooldown %ds",
node.name,
took,
exc.__class__.__name__,
exc,
EMBED_RETRY_COOLDOWN_S,
)
raise RuntimeError(f"tutti i nodi embedding falliti ({len(live)} tentativi)") from last_exc
def get_sparse_model():
global _sparse_model
if _sparse_model is None and SPARSE_AVAILABLE:
_sparse_model = SparseTextEmbedding(model_name="Qdrant/bm25")
return _sparse_model
def sparse_encode(text: str) -> Optional[qm.SparseVector]:
model = get_sparse_model()
if model is None:
return None
emb = next(model.embed(text))
return qm.SparseVector(indices=emb.indices.tolist(), values=emb.values.tolist())
def backfill_sparse(qdrant: Any, collection: str) -> None:
if not SPARSE_AVAILABLE:
return
offset: Any = None
updated = 0
while True:
points, next_offset = qdrant.scroll(
collection_name=collection,
limit=100,
with_payload=["text"],
with_vectors=True,
offset=offset,
)
batch: list[qm.PointStruct] = []
for p in points:
vecs = p.vector or {}
if SPARSE_VECTOR_NAME in vecs:
continue
text = (p.payload or {}).get("text", "")
if not text:
continue
sparse = sparse_encode(text)
if sparse is None:
continue
batch.append(qm.PointStruct(id=p.id, vector={SPARSE_VECTOR_NAME: sparse}))
if batch:
# update_vectors: aggiorna SOLO il vettore sparso, preservando payload e vettore denso
# (upsert parziale sostituirebbe l'intero punto — incidente 2026-08-16)
qdrant.update_vectors(collection_name=collection, points=batch)
updated += len(batch)
if not next_offset:
break
offset = next_offset
if updated:
log.info("backfill sparse: %d record aggiornati", updated)
-135
View File
@@ -1,135 +0,0 @@
"""Guardrail anti-duplicati e similarità pre-scrittura.
Strato 1: hash esatto. Strato 2: cosine (bi-encoder). Strato 3 (opzionale,
GUARDRAIL_RERANK): cross-encoder che conferma o scarta il "quasi-duplicato"
il cosine confonde "stesso argomento" con "stesso fatto", il cross-encoder
legge le coppie e giudica se il nuovo testo sia davvero lo stesso contenuto.
Il reranker è un miglioramento: se non raggiungibile si degrada alla sola
similarità (niente fallimenti di scrittura per un reranker giù).
"""
from __future__ import annotations
import hashlib
import unicodedata
from typing import Any, Optional
from qdrant_client.http import models as qm
import rerank
from config import (
GUARDRAIL_BLOCK_THRESHOLD,
GUARDRAIL_RERANK,
GUARDRAIL_RERANK_BLOCK,
GUARDRAIL_RERANK_SUGGEST,
GUARDRAIL_WARN_THRESHOLD,
log,
)
def normalize_text(text: str) -> str:
s = unicodedata.normalize("NFD", text.lower())
s = "".join(c for c in s if not unicodedata.combining(c))
return " ".join(s.split())
def text_hash(text: str) -> str:
return hashlib.sha256(normalize_text(text).encode("utf-8")).hexdigest()
def find_similar(qdrant: Any, collection: str, text: str, vector: list[float], top_k: int = 5) -> list[dict]:
qfilter = qm.Filter(must=[qm.IsEmptyCondition(is_empty=qm.PayloadField(key="superseded_by"))])
hits = qdrant.query_points(collection_name=collection, query=vector, query_filter=qfilter, limit=top_k, with_payload=True).points
return [
{
"memory_id": h.id,
"score": round(float(h.score), 4),
"text": (h.payload or {}).get("text", ""),
"kind": (h.payload or {}).get("kind", ""),
"project_id": (h.payload or {}).get("project_id", ""),
}
for h in hits
]
async def _cross_scores(text: str, matches: list[dict]) -> Optional[list[float]]:
"""Cross-score (0,1) di (nuovo testo, candidato) per ogni match; None se non disponibile."""
if not GUARDRAIL_RERANK or not rerank.enabled():
return None
docs = [m["text"] or " " for m in matches]
try:
rr = await rerank.rerank(text, docs)
except Exception as exc: # noqa: BLE001
log.warning("guardrail: rerank non disponibile (%s) → decisione solo cosine", exc.__class__.__name__)
return None
if rr is None:
log.warning("guardrail: tutti i nodi rerank non raggiungibili → decisione solo cosine")
return None
scores, _backend, _took = rr
return [rerank.normalize_score(s) for s in scores]
async def decide(qdrant: Any, collection: str, text: str, vector: list[float], topic: Optional[str] = None, parent_id: Optional[str] = None) -> dict:
exact_filter = qm.Filter(must=[
qm.FieldCondition(key="text_hash", match=qm.MatchValue(value=text_hash(text))),
qm.IsEmptyCondition(is_empty=qm.PayloadField(key="superseded_by")),
])
exact = qdrant.query_points(collection_name=collection, query=vector, query_filter=exact_filter, limit=1, with_payload=True).points
if exact:
return {"decision": "BLOCK", "reason": "EXACT_DUPLICATE", "matches": [{"memory_id": exact[0].id, "score": 1.0}]}
matches = find_similar(qdrant, collection, text, vector, top_k=3)
if not matches:
return {"decision": "ALLOW", "reason": "NO_CANDIDATE", "matches": []}
# Strato 3: cross-encoder sulla short-list (giudice "è lo stesso fatto?")
cross = await _cross_scores(text, matches)
if cross is not None:
for m, c in zip(matches, cross):
m["cross_score"] = round(c, 4)
best_cross = max(cross)
best_match = matches[cross.index(best_cross)]
else:
best_cross = None
best_match = matches[0]
top1 = matches[0]["score"]
hierarchical = (topic or parent_id) and any(m.get("memory_id") != parent_id for m in matches)
if top1 >= GUARDRAIL_BLOCK_THRESHOLD:
if hierarchical:
return {"decision": "WARN", "reason": "HIERARCHICAL_SUBTOPIC", "matches": matches}
if best_cross is not None:
if best_cross >= GUARDRAIL_RERANK_BLOCK:
return {"decision": "BLOCK", "reason": "CROSS_DUP_CONFIRMED", "matches": matches}
return {"decision": "WARN", "reason": "CROSS_DUP_WEAK", "matches": matches,
"message": "Similarità alta ma il cross-encoder non conferma lo stesso fatto: probabilmente correlati, non duplicati."}
return {"decision": "BLOCK", "reason": "KNOWN_SOLUTION", "matches": matches}
if top1 >= GUARDRAIL_WARN_THRESHOLD:
if best_cross is not None and best_cross >= GUARDRAIL_RERANK_BLOCK:
return {"decision": "BLOCK", "reason": "CROSS_DUP_CONFIRMED", "matches": matches}
d = {"decision": "WARN", "reason": "MODERATE_SIMILARITY", "matches": matches}
if best_cross is not None and best_cross >= GUARDRAIL_RERANK_SUGGEST:
d["suggestion"] = {
"supersedes_id": best_match["memory_id"],
"cross_score": round(best_cross, 4),
"message": "Sembra un aggiornamento del record indicato: valuta supersedes_id.",
}
return d
# Cosine sotto la soglia WARN, ma cross-encoder che conferma un duplicato
# parafrasato sfuggito al bi-encoder.
if best_cross is not None and best_cross >= GUARDRAIL_RERANK_BLOCK:
return {"decision": "BLOCK", "reason": "CROSS_DUP_LOW_COSINE", "matches": matches}
return {"decision": "ALLOW", "reason": "NEW_SOLUTION", "matches": matches}
def parse_ts(value: Optional[str]) -> Optional[float]:
from datetime import datetime
if not value:
return None
try:
return datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp()
except ValueError:
return None
-111
View File
@@ -1,111 +0,0 @@
"""Memory Gateway — bootstrap FastAPI, lifecycle e middleware.
Gli endpoint e la logica di dominio sono separati in moduli:
config, models, state, audit, guardrail, embed, store, metrics, cleanup,
routes. Il contratto HTTP resta invariato.
"""
from __future__ import annotations
import asyncio
import time
import uuid
from contextlib import asynccontextmanager
import uvicorn
from fastapi import FastAPI, Request
from qdrant_client.http import models as qm
import cleanup
import embed as embedding
import metrics
import rerank
import state
from config import (
COLLECTION,
EMBED_DIM,
METRICS_ENABLED,
SPARSE_VECTOR_NAME,
GATEWAY_VERSION,
log,
)
from routes import router
# Alias utili per compatibilità con import/debug locali; lo stato effettivo è in state.py.
qdrant = state.qdrant
embed = embedding.embed
state.embed = embedding.embed
state.sparse_encode = embedding.sparse_encode
async def _lifespan(_app: FastAPI):
"""Crea collection/indici e avvia i loop periodici."""
collections = state.qdrant.get_collections().collections
if not any(c.name == COLLECTION for c in collections):
state.qdrant.create_collection(
collection_name=COLLECTION,
vectors_config=qm.VectorParams(size=EMBED_DIM, distance=qm.Distance.COSINE),
sparse_vectors_config={SPARSE_VECTOR_NAME: qm.SparseVectorParams(modifier=qm.Modifier.IDF)},
)
for field in ("agent_id", "project_id", "scope", "kind", "supersedes_id", "superseded_by", "text_hash", "parent_id", "level", "topic"):
state.qdrant.create_payload_index(collection_name=COLLECTION, field_name=field, field_schema=qm.PayloadSchemaType.KEYWORD)
state.qdrant.create_payload_index(collection_name=COLLECTION, field_name="text", field_schema=qm.PayloadSchemaType.TEXT)
log.info("collection %s creata con indici (dense + sparse %s)", COLLECTION, SPARSE_VECTOR_NAME)
else:
log.info("collection %s già esistente", COLLECTION)
for field in ("parent_id", "level", "topic"):
try:
state.qdrant.create_payload_index(collection_name=COLLECTION, field_name=field, field_schema=qm.PayloadSchemaType.KEYWORD)
except Exception: # noqa: BLE001
pass
info = state.qdrant.get_collection(COLLECTION)
sparse_vectors = (info.config.params.sparse_vectors or {}) if info.config and info.config.params else {}
if SPARSE_VECTOR_NAME not in sparse_vectors:
state.qdrant.create_vector_name(COLLECTION, SPARSE_VECTOR_NAME, qm.SparseVectorNameConfig(sparse=qm.SparseVectorConfig(modifier=qm.Modifier.IDF)))
log.info("sparse vector %s aggiunto alla collection esistente", SPARSE_VECTOR_NAME)
embedding.backfill_sparse(state.qdrant, COLLECTION)
cleanup_task = asyncio.create_task(cleanup.loop(state.qdrant, COLLECTION, state.invalidate_meta))
metrics_task = asyncio.create_task(metrics.push_loop(state.qdrant, COLLECTION, embedding.get_http)) if METRICS_ENABLED else None
try:
yield
finally:
cleanup_task.cancel()
try:
await cleanup_task
except asyncio.CancelledError:
pass
if metrics_task is not None:
metrics_task.cancel()
try:
await metrics_task
except asyncio.CancelledError:
pass
await embedding.close_http()
await rerank.close_http()
app = FastAPI(title="Memory Gateway", version=GATEWAY_VERSION, lifespan=_lifespan)
app.include_router(router)
@app.middleware("http")
async def request_id_middleware(request: Request, call_next):
rid = request.headers.get("X-Request-ID") or str(uuid.uuid4())
state.request_id.set(rid)
response = await call_next(request)
response.headers["X-Request-ID"] = rid
return response
@app.middleware("http")
async def metrics_middleware(request: Request, call_next):
start = time.monotonic()
response = await call_next(request)
route = request.scope.get("route")
endpoint = route.path if route else request.url.path
metrics.record_request(endpoint, time.monotonic() - start, response.status_code)
return response
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=8080)
-94
View File
@@ -1,94 +0,0 @@
"""Metriche in-memory e push Prometheus/VictoriaMetrics."""
from __future__ import annotations
import time
from typing import Any
from config import VM_PUSH_INTERVAL, VM_PUSH_URL, _metrics, log
def record_request(endpoint: str, duration: float, status_code: int) -> None:
_metrics["requests"][endpoint] += 1
_metrics["duration_sum"][endpoint] += duration
_metrics["duration_count"][endpoint] += 1
if status_code >= 400:
_metrics["errors"][(endpoint, status_code)] += 1
def record_search(hits: int) -> None:
_metrics["search_queries"] += 1
_metrics["search_hits"] += hits
def record_rerank(backend: str, ok: bool, took_ms: int) -> None:
_metrics["rerank_calls"][(backend, "ok" if ok else "fail")] += 1
_metrics["rerank_duration_sum"][backend] += took_ms
def record_embed(backend: str, ok: bool, took_ms: int) -> None:
_metrics["embed_calls"][(backend, "ok" if ok else "fail")] += 1
_metrics["embed_duration_sum"][backend] += took_ms
def snapshot(qdrant: Any, collection: str) -> dict:
try:
points = qdrant.get_collection(collection).points_count
except Exception: # noqa: BLE001
points = None
return {
"requests": dict(_metrics["requests"]),
"avg_duration_ms": {
endpoint: round(_metrics["duration_sum"][endpoint] / _metrics["duration_count"][endpoint] * 1000, 2)
for endpoint in _metrics["duration_count"]
},
"errors": {f"{endpoint}:{status}": count for (endpoint, status), count in _metrics["errors"].items()},
"search_queries": _metrics["search_queries"],
"search_hits": _metrics["search_hits"],
"rerank_calls": {f"{backend}:{outcome}": count for (backend, outcome), count in _metrics["rerank_calls"].items()},
"rerank_avg_ms": {backend: round(total / _metrics["rerank_calls"][(backend, "ok")], 2) for backend, total in _metrics["rerank_duration_sum"].items() if _metrics["rerank_calls"][(backend, "ok")]},
"embed_calls": {f"{backend}:{outcome}": count for (backend, outcome), count in _metrics["embed_calls"].items()},
"embed_avg_ms": {backend: round(total / _metrics["embed_calls"][(backend, "ok")], 2) for backend, total in _metrics["embed_duration_sum"].items() if _metrics["embed_calls"][(backend, "ok")]},
"qdrant_retries": _metrics["qdrant_retries"],
"points": points,
}
def prometheus_lines(qdrant: Any, collection: str) -> list[str]:
lines: list[str] = []
for endpoint, count in _metrics["requests"].items():
lines.append(f'qmem_requests_total{{endpoint="{endpoint}"}} {count}')
for endpoint, total in _metrics["duration_sum"].items():
count = _metrics["duration_count"][endpoint]
lines.append(f'qmem_request_duration_seconds_sum{{endpoint="{endpoint}"}} {total:.6f}')
lines.append(f'qmem_request_duration_seconds_count{{endpoint="{endpoint}"}} {count}')
for (endpoint, status), count in _metrics["errors"].items():
lines.append(f'qmem_errors_total{{endpoint="{endpoint}",status="{status}"}} {count}')
lines.append(f"qmem_search_queries_total {_metrics['search_queries']}")
lines.append(f"qmem_search_hits_total {_metrics['search_hits']}")
for (backend, outcome), count in _metrics["rerank_calls"].items():
lines.append(f'qmem_rerank_calls_total{{backend="{backend}",outcome="{outcome}"}} {count}')
for backend, s in _metrics["rerank_duration_sum"].items():
lines.append(f'qmem_rerank_duration_seconds_sum{{backend="{backend}"}} {s / 1000:.6f}')
for (backend, outcome), count in _metrics["embed_calls"].items():
lines.append(f'qmem_embed_calls_total{{backend="{backend}",outcome="{outcome}"}} {count}')
for backend, s in _metrics["embed_duration_sum"].items():
lines.append(f'qmem_embed_duration_seconds_sum{{backend="{backend}"}} {s / 1000:.6f}')
lines.append(f"qmem_qdrant_retries_total {_metrics['qdrant_retries']}")
try:
lines.append(f"qmem_points {qdrant.get_collection(collection).points_count}")
except Exception: # noqa: BLE001
pass
return lines
async def push_loop(qdrant: Any, collection: str, get_http) -> None:
while True:
try:
now_ms = int(time.time() * 1000)
body = "\n".join(f"{line} {now_ms}" for line in prometheus_lines(qdrant, collection)) + "\n"
response = await get_http().post(VM_PUSH_URL, content=body, headers={"Content-Type": "text/plain"})
if response.status_code >= 300:
log.warning("metrics push: HTTP %s", response.status_code)
except Exception as exc: # noqa: BLE001
log.warning("metrics push error: %s", exc)
await __import__("asyncio").sleep(VM_PUSH_INTERVAL)
-80
View File
@@ -1,80 +0,0 @@
"""Schemi Pydantic del Memory Gateway."""
from __future__ import annotations
from datetime import datetime
from typing import Literal, Optional
from pydantic import BaseModel, Field, field_validator
from config import MAX_TEXT_LEN
class MemoryLink(BaseModel):
target_id: str = Field(..., description="UUID del record target collegato")
predicate: str = Field(default="part_of", max_length=64, description="Tipo di relazione: parent_of, part_of, relates_to, supersedes...")
weight: float = Field(default=1.0, ge=0.0, le=1.0)
class MemoryIn(BaseModel):
text: str = Field(min_length=1, max_length=MAX_TEXT_LEN)
kind: Literal["decision", "fact", "episode", "preference"] = "fact"
agent_id: Optional[str] = Field(default=None, max_length=64, description="Solo provenienza, nessun isolamento")
project_id: str = Field(min_length=1, max_length=64, description="OBBLIGATORIO: progetto/dominio di appartenenza (kebab-case)")
scope: Literal["agent", "project", "org"] = "agent"
source: Optional[str] = Field(default=None, max_length=256)
confidence: Literal["high", "medium", "low"] = Field(default="medium", description="Affidabilità del record")
expires_at: Optional[str] = None
supersedes_id: Optional[str] = None
supersede_reason: Optional[str] = Field(default=None, max_length=512)
parent_id: Optional[str] = Field(default=None, description="UUID del record genitore per gerarchia/subtopic")
level: Optional[Literal["L1_ROOT", "L2_SUBTOPIC", "L3_DETAIL"]] = Field(default=None, description="Livello gerarchico")
topic: Optional[str] = Field(default=None, max_length=128, description="Topic gerarchico")
links: Optional[list[MemoryLink]] = Field(default=None, description="Collegamenti semantici e relazionali")
importance: float = Field(default=0.5, ge=0.0, le=1.0, description="Importanza stabile del record (usata nello score composito)")
private: bool = Field(default=False, description="Riservato: escluso dalle ricerche standard, visibile solo con include_private o topic esplicito")
@field_validator("expires_at")
@classmethod
def _validate_expires_at(cls, v: Optional[str]) -> Optional[str]:
if v is None:
return v
try:
datetime.fromisoformat(v.replace("Z", "+00:00"))
except ValueError:
raise ValueError("expires_at deve essere una data ISO 8601 valida (es. 2026-09-01T00:00:00Z)")
return v
class ScoreIn(BaseModel):
"""Primitiva di scoring cross-encoder (usata da estensione e job di consolidamento)."""
query: str = Field(min_length=1, max_length=512)
documents: list[str] = Field(min_length=1, max_length=32)
class SearchIn(BaseModel):
query: str = Field(min_length=1, max_length=512)
kind: Optional[Literal["decision", "fact", "episode", "preference"]] = None
project_id: Optional[str] = None
scope: Optional[Literal["agent", "project", "org"]] = None
include_superseded: bool = False
min_score: Optional[float] = Field(default=None, ge=0.0, le=1.0)
top_k: int = Field(default=5, ge=1, le=20)
hybrid: bool = False
parent_id: Optional[str] = None
level: Optional[Literal["L1_ROOT", "L2_SUBTOPIC", "L3_DETAIL"]] = None
topic: Optional[str] = None
include_private: bool = Field(default=False, description="Includi i record privati (solo ricerche esplicite)")
rerank: Optional[bool] = Field(default=None, description="Override per-query dello stadio rerank (None = default server)")
queries: Optional[list[str]] = Field(default=None, max_length=3, description="Varianti di query (max 3): pool unito, dedup e rerank unico")
@field_validator("queries")
@classmethod
def _validate_queries(cls, v: Optional[list[str]]) -> Optional[list[str]]:
if v is None:
return v
cleaned = [q.strip() for q in v if q and q.strip()]
if len(cleaned) != len(v):
raise ValueError("le query non devono essere vuote")
return cleaned
queries: Optional[list[str]] = Field(default=None, max_length=3, description="Varianti di query (max 3): pool unito, dedup e rerank unico")
-2
View File
@@ -1,2 +0,0 @@
# Dipendenze di sviluppo (test): installare con pip install -r requirements-dev.txt
pytest==8.3.4
-6
View File
@@ -1,6 +0,0 @@
fastapi==0.115.6
uvicorn[standard]==0.34.0
qdrant-client==1.19.0
httpx==0.28.1
pydantic==2.10.4
fastembed==0.5.1
-179
View File
@@ -1,179 +0,0 @@
"""Stadio di re-ranking (cross-encoder) con catena di fallback resiliente.
La catena è definita da RERANK_CHAIN (JSON): il primo nodo raggiungibile vince.
Dopo un fallimento il nodo entra in cooldown (RERANK_RETRY_COOLDOWN_S) e la
richiesta passa al successivo; se tutti i nodi sono in cooldown si ritenta
comunque il primo (meglio di un fallimento immediato). Se nessun nodo risponde
la ricerca degrada con grazia all'ordine di fusione ibrida (nessun errore al
client): il reranking è un miglioramento, non una dipendenza.
"""
from __future__ import annotations
import json
import math
import time
from dataclasses import dataclass
from typing import Optional
import httpx
import metrics
from config import (
RERANK_CHAIN,
RERANK_ENABLED,
RERANK_MAX_DOC_CHARS,
RERANK_MODEL,
RERANK_RETRY_COOLDOWN_S,
RERANK_TIMEOUT_MS,
log,
)
@dataclass(frozen=True)
class RerankNode:
"""Un endpoint reranker nella catena di fallback."""
name: str
url: str
key: str
timeout_ms: int
def parse_chain(raw: str) -> list[RerankNode]:
"""Parsa RERANK_CHAIN: JSON [{name, url, key, timeout_ms}]. URL senza schema → scartato."""
try:
entries = json.loads(raw) if raw else []
except (json.JSONDecodeError, TypeError):
log.error("RERANK_CHAIN non è JSON valido: reranking disattivato")
return []
if not isinstance(entries, list):
log.error("RERANK_CHAIN non è una lista: reranking disattivato")
return []
nodes: list[RerankNode] = []
for entry in entries:
if not isinstance(entry, dict) or not entry.get("url"):
continue
url = str(entry["url"]).rstrip("/")
if not url.startswith(("http://", "https://")):
continue
nodes.append(
RerankNode(
name=str(entry.get("name") or url),
url=url,
key=str(entry.get("key") or ""),
timeout_ms=int(entry.get("timeout_ms", RERANK_TIMEOUT_MS)),
)
)
return nodes
_chain: Optional[list[RerankNode]] = None
_down_until: dict[str, float] = {} # url → monotonic deadline del cooldown
_http: Optional[httpx.AsyncClient] = None
def _get_chain() -> list[RerankNode]:
global _chain
if _chain is None:
_chain = parse_chain(RERANK_CHAIN)
return _chain
def reset_chain_cache() -> None:
"""Forza il re-parse della catena (usato dai test)."""
global _chain
_chain = None
_down_until.clear()
def get_http() -> httpx.AsyncClient:
global _http
if _http is None:
_http = httpx.AsyncClient(timeout=30)
return _http
async def close_http() -> None:
global _http
if _http is not None:
await _http.aclose()
_http = None
def enabled() -> bool:
"""Reranking attivo: flag env + catena configurata non vuota."""
return RERANK_ENABLED and bool(_get_chain())
def live_nodes() -> tuple[list[RerankNode], bool]:
"""Nodi fuori cooldown; all_down=True se nessun nodo è live (forza retry totale)."""
chain = _get_chain()
now = time.monotonic()
live = [n for n in chain if _down_until.get(n.url, 0) <= now]
return live, bool(chain) and len(live) < len(chain)
async def rerank(query: str, docs: list[str]) -> Optional[tuple[list[float], str, int]]:
"""Reranka i documenti rispetto alla query tramite la catena di fallback.
Ritorna (scores, backend_name, took_ms) dove scores è allineato a docs
(logit sigmoide in [0,1]), oppure None se tutti i nodi falliscono.
"""
chain = _get_chain()
if not chain or not docs:
return None
# Troncamento dei documenti: limita il costo di inferenza (i cross-encoder
# scala con la lunghezza della coppia query+doc) e evita input oltre il ctx.
docs = [d[:RERANK_MAX_DOC_CHARS] for d in docs]
live, all_down = live_nodes()
if not live:
# tutti in cooldown: ritenta comunque il primo (meglio di un fallimento immediato)
live = [chain[0]]
payload = {"model": RERANK_MODEL, "query": query, "documents": docs, "top_n": len(docs)}
started = time.monotonic()
for node in live:
headers = {"Content-Type": "application/json"}
if node.key:
headers["Authorization"] = f"Bearer {node.key}"
try:
t0 = time.monotonic()
response = await get_http().post(
f"{node.url}/v1/rerank",
json=payload,
headers=headers,
timeout=httpx.Timeout(node.timeout_ms / 1000.0),
)
response.raise_for_status()
data = response.json()
# Il risultato è [{index, relevance_score}] ordinato per rilevanza:
# riportiamo ogni score alla posizione originaria del documento.
scores = [0.0] * len(docs)
for item in data.get("results", []):
idx = int(item["index"])
if 0 <= idx < len(docs):
scores[idx] = float(item.get("relevance_score", 0.0))
took = int((time.monotonic() - started) * 1000)
metrics.record_rerank(node.name, True, took)
return scores, node.name, took
except (httpx.HTTPError, ValueError, KeyError, TypeError) as exc:
took = int((time.monotonic() - t0) * 1000)
_down_until[node.url] = time.monotonic() + RERANK_RETRY_COOLDOWN_S
metrics.record_rerank(node.name, False, took)
log.warning(
"rerank: nodo '%s' fallito dopo %dms (%s: %s) → cooldown %ds",
node.name,
took,
exc.__class__.__name__,
exc,
RERANK_RETRY_COOLDOWN_S,
)
return None
def normalize_score(logit: float) -> float:
"""Sigmoide: logit di rilevanza → punteggio [0,1] leggibile nei risultati."""
if logit >= 0:
z = math.exp(-logit)
return 1.0 / (1.0 + z)
z = math.exp(logit)
return z / (1.0 + z)
-333
View File
@@ -1,333 +0,0 @@
"""Endpoint HTTP del Memory Gateway."""
from __future__ import annotations
import hashlib
import time
import uuid
from collections import Counter
from datetime import datetime
from typing import Any, Optional
from fastapi import APIRouter, Depends, Header, HTTPException, Request
from qdrant_client.http import models as qm
import config
import embed
import guardrail
import metrics
import rerank
import state
import store
from audit import audit, now_iso, require_auth
from config import (
API_KEYS,
COLLECTION,
EMBED_DIM,
EMBED_MODEL,
GATEWAY_VERSION,
GUARDRAIL_BLOCK_THRESHOLD,
GUARDRAIL_ENABLED,
GUARDRAIL_RERANK_BLOCK,
GUARDRAIL_SUPERSEDE_CHECK,
GUARDRAIL_SUPERSEDE_MIN,
GUARDRAIL_VERSION,
GUARDRAIL_WARN_THRESHOLD,
MAX_TEXT_LEN,
RERANK_CANDIDATES,
SCORE_DECAY_HALF_LIFE_DAYS,
SCORE_W_AUTHORITY,
SCORE_W_IMPORTANCE,
SCORE_W_RECENCY,
SCORE_W_RELEVANCE,
)
from models import MemoryIn, ScoreIn, SearchIn
router = APIRouter()
@router.post("/v1/memories")
async def add_memory(
body: MemoryIn,
key: str = Depends(require_auth),
idempotency_key: Optional[str] = Header(default=None, alias="Idempotency-Key"),
) -> dict:
idem_key = f"{key}:{idempotency_key}" if idempotency_key else None
if idem_key:
state.idempotency_cleanup()
existing = state.idempotency.get(idem_key)
if existing:
if existing["hash"] != state.payload_hash(body):
raise HTTPException(status_code=409, detail="Idempotency-Key già usata con payload diverso")
audit(key, "create_replay", idempotency_key=idempotency_key[:16])
return existing["response"]
memory_id = str(uuid.uuid4())
superseded_id: Optional[str] = None
supersede_warning: Optional[dict] = None
if body.supersedes_id:
old = state.qdrant.retrieve(collection_name=COLLECTION, ids=[body.supersedes_id], with_payload=True)
if not old:
raise HTTPException(status_code=404, detail="Memoria da supersedere non trovata")
if old[0].payload.get("superseded_by"):
raise HTTPException(status_code=409, detail="La memoria è già stata superseduta: correggi la versione attiva")
superseded_id = body.supersedes_id
# Verifica lineage (B): la correzione deve parlare dello stesso fatto del record vecchio
if config.GUARDRAIL_SUPERSEDE_CHECK and rerank.enabled() and (old[0].payload or {}).get("text"):
rr = await rerank.rerank(body.text, [(old[0].payload or {}).get("text", "")])
if rr:
cross = rerank.normalize_score(rr[0][0])
if cross < config.GUARDRAIL_SUPERSEDE_MIN:
supersede_warning = {
"cross_score": round(cross, 4),
"message": "La correzione non sembra riguardare lo stesso fatto del record originale: verifica il lineage.",
}
audit(key, "supersede_weak_cross", old_id=superseded_id, cross_score=round(cross, 4))
vector = await state.embed(body.text)
sparse = state.sparse_encode(body.text)
similarity_guardrail: Optional[dict] = None
if config.GUARDRAIL_ENABLED and not body.supersedes_id:
similarity_guardrail = await guardrail.decide(state.qdrant, COLLECTION, body.text, vector, topic=body.topic, parent_id=body.parent_id)
if similarity_guardrail["decision"] == "BLOCK":
audit(key, "create_blocked", kind=body.kind, agent_id=body.agent_id or "shared", reason=similarity_guardrail["reason"], matches=[m["memory_id"] for m in similarity_guardrail["matches"]])
raise HTTPException(
status_code=409,
detail={
"error": "duplicate_memory",
"reason": similarity_guardrail["reason"],
"matches": similarity_guardrail["matches"],
"message": "Memoria già presente o quasi identica: usa supersedes_id per correggere la versione attiva, oppure riformula il contenuto.",
},
)
payload: dict[str, Any] = {
"text": body.text,
"kind": body.kind,
"agent_id": body.agent_id or "shared",
"project_id": body.project_id,
"scope": body.scope,
"source": body.source,
"confidence": body.confidence,
"private": body.private,
"created_at": now_iso(),
"expires_at": guardrail.parse_ts(body.expires_at),
"supersedes_id": superseded_id,
"supersede_reason": body.supersede_reason,
"parent_id": body.parent_id,
"level": body.level,
"topic": body.topic,
"importance": body.importance,
"links": [link.model_dump() for link in body.links] if body.links else None,
"embedding_model": EMBED_MODEL,
"text_hash": guardrail.text_hash(body.text),
}
if similarity_guardrail:
payload["guardrail"] = {
"version": GUARDRAIL_VERSION,
"decision": similarity_guardrail["decision"],
"reason": similarity_guardrail["reason"],
"matches": similarity_guardrail["matches"],
}
if similarity_guardrail.get("suggestion"):
payload["guardrail"]["suggestion"] = similarity_guardrail["suggestion"]
point_vector: dict[str, Any] = {"": vector}
if sparse is not None:
point_vector["bm25"] = sparse
state.qdrant.upsert(collection_name=COLLECTION, points=[qm.PointStruct(id=memory_id, vector=point_vector, payload=payload)])
state.invalidate_meta()
reparented_count = 0
if superseded_id:
state.qdrant.set_payload(
collection_name=COLLECTION,
payload={"superseded_by": memory_id, "superseded_at": now_iso(), "supersede_reason": body.supersede_reason},
points=[superseded_id],
)
audit(key, "supersede", old_id=superseded_id, new_id=memory_id, kind=body.kind, agent_id=payload["agent_id"])
reparented_count = store.reparent_active_children(state.qdrant, COLLECTION, superseded_id, memory_id)
if reparented_count:
audit(key, "reparent", old_id=superseded_id, new_id=memory_id, count=reparented_count)
else:
audit(key, "create", memory_id=memory_id, kind=body.kind, agent_id=payload["agent_id"], guardrail=payload.get("guardrail", {}).get("decision", "ALLOW"))
response = {"memory_id": memory_id, "created_at": payload["created_at"], "supersedes_id": superseded_id, "reparented": reparented_count}
if supersede_warning:
response["supersede_warning"] = supersede_warning
if idem_key:
state.idempotency[idem_key] = {"hash": state.payload_hash(body), "response": response, "ts": time.time()}
return response
def _composite_score(r: dict, now: float) -> float:
"""Score composito (C): rerank + importance + recency-decay + authority, pesi normalizzati."""
try:
age_days = max(0.0, (now - datetime.fromisoformat(str(r.get("created_at")).replace("Z", "+00:00")).timestamp()) / 86400.0)
except (ValueError, TypeError, AttributeError):
age_days = 0.0
recency = pow(0.5, age_days / SCORE_DECAY_HALF_LIFE_DAYS)
authority = {"high": 1.0, "medium": 0.7, "low": 0.4}.get(r.get("confidence"), 0.7)
importance = float(r.get("importance", 0.5) or 0.5)
total_w = SCORE_W_RELEVANCE + SCORE_W_IMPORTANCE + SCORE_W_RECENCY + SCORE_W_AUTHORITY
raw = (
SCORE_W_RELEVANCE * float(r["rerank_score"])
+ SCORE_W_IMPORTANCE * importance
+ SCORE_W_RECENCY * recency
+ SCORE_W_AUTHORITY * authority
)
return raw / total_w if total_w else raw
@router.post("/v1/memories:search")
async def search_memories(body: SearchIn, key: str = Depends(require_auth)) -> dict:
use_rerank = rerank.enabled() and body.rerank is not False
# Con reranking attivo recuperiamo più candidati di top_k per dare margine allo stadio di rerank
limit = max(body.top_k, RERANK_CANDIDATES) if use_rerank else body.top_k
# Multi-query (E): varianti della stessa query, pool unito con dedup (la prima ha priorità)
queries = list(dict.fromkeys([body.query] + [q for q in (body.queries or []) if q]))[:3]
merged: dict[str, Any] = {}
for q in queries:
vector = await state.embed(q)
sparse = state.sparse_encode(q) if body.hybrid else None
hits = store.search(state.qdrant, COLLECTION, body, vector, sparse, limit=limit)
for h in hits:
merged.setdefault(h.id, h)
results = store.format_results(list(merged.values())[:limit])
rerank_info: dict = {"enabled": use_rerank, "used": False, "queries_used": len(queries)}
if use_rerank and len(results) >= 2:
rr = await rerank.rerank(body.query, [r["text"] or "" for r in results])
if rr:
scores, backend, took_ms = rr
now = time.time()
for r, s in zip(results, scores):
r["rerank_score"] = round(rerank.normalize_score(s), 4)
r["composite_score"] = round(_composite_score(r, now), 4)
results.sort(key=lambda r: (r["composite_score"], r["rerank_score"]), reverse=True)
rerank_info.update(used=True, backend=backend, took_ms=took_ms, candidates=len(results))
else:
rerank_info["reason"] = "tutti i nodi rerank non raggiungibili (ordine di fusione preservato)"
elif use_rerank:
rerank_info["reason"] = "candidati insufficienti"
results = results[: body.top_k]
audit(
key,
"search",
query_hash=hashlib.sha256(body.query.encode()).hexdigest()[:16],
top_k=body.top_k,
min_score=body.min_score,
hits=len(results),
rerank_backend=rerank_info.get("backend"),
queries_used=len(queries),
)
metrics.record_search(len(results))
return {"results": results, "min_score": body.min_score, "total_hits": len(results), "rerank": rerank_info}
@router.post("/v1/score")
async def score(body: ScoreIn, key: str = Depends(require_auth)) -> dict:
"""Primitiva cross-encoder: rilevanza (query, documento) in [0,1] via catena rerank.
Building block per estensione (validazione estrattore, lineage check) e job
di consolidamento; 503 se tutti i nodi della catena non raggiungibili."""
rr = await rerank.rerank(body.query, body.documents)
if rr is None:
raise HTTPException(status_code=503, detail="nessun nodo rerank raggiungibile")
scores, backend, took_ms = rr
return {
"scores": [round(rerank.normalize_score(s), 4) for s in scores],
"raw": [round(s, 4) for s in scores],
"backend": backend,
"took_ms": took_ms,
}
@router.get("/v1/memories/{memory_id}")
async def get_memory(memory_id: str, key: str = Depends(require_auth)) -> dict:
try:
point = state.qdrant.retrieve(collection_name=COLLECTION, ids=[memory_id], with_payload=True)
except Exception: # noqa: BLE001 — id non-UUID o payload malformato → non trovato, non 500
point = []
if not point:
raise HTTPException(status_code=404, detail="Memoria non trovata")
audit(key, "get", memory_id=memory_id)
return {"memory_id": memory_id, **point[0].payload}
@router.delete("/v1/memories/{memory_id}")
async def delete_memory(memory_id: str, key: str = Depends(require_auth)) -> dict:
point = state.qdrant.retrieve(collection_name=COLLECTION, ids=[memory_id], with_payload=True)
if not point:
raise HTTPException(status_code=404, detail="Memoria non trovata")
state.qdrant.delete(collection_name=COLLECTION, points_selector=[memory_id])
state.invalidate_meta()
audit(key, "delete", memory_id=memory_id)
return {"deleted": memory_id}
@router.get("/v1/meta/overview")
async def meta_overview(key: str = Depends(require_auth)) -> dict:
now = time.time()
cached = state.meta_cache.get("overview")
if cached and now - cached["ts"] < 60:
audit(key, "meta", cached=True)
return {**cached["data"], "cached": True}
scope_kinds: dict[str, Counter] = {}
projects: Counter = Counter()
agents: Counter = Counter()
total = 0
superseded = 0
offset: Any = None
while True:
points, next_offset = state.qdrant.scroll(collection_name=COLLECTION, limit=1000, with_payload=["scope", "kind", "project_id", "agent_id", "superseded_by"], with_vectors=False, offset=offset)
for point in points:
payload = point.payload
total += 1
scope = payload.get("scope", "agent")
kind = payload.get("kind", "fact")
scope_kinds.setdefault(scope, Counter())[kind] += 1
if payload.get("project_id"):
projects[payload["project_id"]] += 1
agents[payload.get("agent_id", "shared")] += 1
if payload.get("superseded_by"):
superseded += 1
if not next_offset:
break
offset = next_offset
data = {
"scopes": [{"scope": scope, "count": sum(counts.values()), "kinds": [{"kind": kind, "count": count} for kind, count in sorted(counts.items())]} for scope, counts in sorted(scope_kinds.items())],
"projects": [{"project_id": project, "count": count} for project, count in projects.most_common()],
"agents": [{"agent_id": agent, "count": count} for agent, count in agents.most_common()],
"superseded": superseded,
"total": total,
}
state.meta_cache["overview"] = {"ts": now, "data": data}
audit(key, "meta", cached=False, total=total)
return {**data, "cached": False}
@router.get("/v1/status")
async def status(request: Request) -> dict:
ip = request.client.host if request.client else "unknown"
now = time.monotonic()
window = state.status_ratelimit.setdefault(ip, [])
window[:] = [t for t in window if now - t < 60]
if len(window) >= state.STATUS_RATE_LIMIT_PER_MIN:
raise HTTPException(status_code=429, detail="Rate limit superato")
window.append(now)
info = state.qdrant.get_collection(COLLECTION)
return {"status": "ok", "collection": COLLECTION, "points": info.points_count, "embedding_model": EMBED_MODEL, "embedding_dim": EMBED_DIM, "access": "shared", "api_keys": len(API_KEYS), "version": GATEWAY_VERSION, "git_commit": __import__("config").GIT_COMMIT, "guardrail_version": GUARDRAIL_VERSION}
@router.get("/v1/version")
async def version() -> dict:
return {"version": GATEWAY_VERSION, "git_commit": __import__("config").GIT_COMMIT, "guardrail_version": GUARDRAIL_VERSION, "guardrail_enabled": GUARDRAIL_ENABLED, "guardrail_block_threshold": GUARDRAIL_BLOCK_THRESHOLD, "guardrail_warn_threshold": GUARDRAIL_WARN_THRESHOLD, "embedding_model": EMBED_MODEL, "collection": COLLECTION, "embed_nodes": [n.name for n in embed.chain_nodes()], "rerank_enabled": rerank.enabled(), "rerank_model": config.RERANK_MODEL, "rerank_nodes": [n.name for n in rerank._get_chain()]}
@router.get("/v1/metrics")
async def metrics_endpoint(key: str = Depends(require_auth)) -> dict:
return metrics.snapshot(state.qdrant, COLLECTION)
-83
View File
@@ -1,83 +0,0 @@
"""Stato runtime condiviso tra bootstrap e route."""
from __future__ import annotations
import contextvars
import hashlib
import json
import time
from typing import Any
import httpx
from qdrant_client import QdrantClient
from config import QDRANT_API_KEY, QDRANT_URL, QDRANT_RETRIES, log, _metrics
class ResilientQdrant:
"""Proxy del client Qdrant che ritenta i metodi su errori di transport
(connessione/timeout transienti, es. riavvio del container Qdrant).
Gli errori applicativi (404, validazione) non vengono ritentati."""
def __init__(self, client: Any, attempts: int = QDRANT_RETRIES, backoff_s: float = 0.4):
self._client = client
self._attempts = max(1, attempts)
self._backoff = backoff_s
@staticmethod
def _transient(exc: Exception) -> bool:
if isinstance(exc, httpx.TransportError):
return True
return type(exc).__name__ in ("ConnectionError", "TimeoutError")
def __getattr__(self, name: str) -> Any:
attr = getattr(self._client, name)
if not callable(attr):
return attr
def wrapped(*args: Any, **kwargs: Any) -> Any:
for attempt in range(self._attempts):
try:
return attr(*args, **kwargs)
except Exception as exc: # noqa: BLE001
if attempt == self._attempts - 1 or not self._transient(exc):
raise
delay = self._backoff * (2**attempt)
_metrics["qdrant_retries"] += 1
log.warning(
"qdrant.%s: errore transiente (%s: %s) → retry %d/%d tra %.1fs",
name,
exc.__class__.__name__,
exc,
attempt + 2,
self._attempts,
delay,
)
time.sleep(delay)
return wrapped
qdrant = ResilientQdrant(QdrantClient(url=QDRANT_URL, api_key=QDRANT_API_KEY))
request_id: contextvars.ContextVar[str] = contextvars.ContextVar("request_id", default="-")
ratelimit: dict[str, list[float]] = {}
status_ratelimit: dict[str, list[float]] = {}
idempotency: dict[str, dict[str, Any]] = {}
meta_cache: dict[str, Any] = {}
STATUS_RATE_LIMIT_PER_MIN = 30
IDEMPOTENCY_TTL_SECONDS = 24 * 3600
def payload_hash(body: Any) -> str:
canonical = json.dumps(body.model_dump(), sort_keys=True, default=str)
return hashlib.sha256(canonical.encode()).hexdigest()
def idempotency_cleanup() -> None:
now = time.time()
expired = [k for k, v in idempotency.items() if now - v["ts"] > IDEMPOTENCY_TTL_SECONDS]
for key in expired:
idempotency.pop(key, None)
def invalidate_meta() -> None:
meta_cache.clear()
-96
View File
@@ -1,96 +0,0 @@
"""Operazioni Qdrant condivise dalle route."""
from __future__ import annotations
import hashlib
from typing import Any, Optional
from qdrant_client.http import models as qm
from config import SPARSE_VECTOR_NAME
from models import SearchIn
def search_filter(body: SearchIn) -> qm.Filter | None:
must: list[Any] = []
for key in ("kind", "project_id", "scope", "parent_id", "level", "topic"):
value = getattr(body, key)
if value:
must.append(qm.FieldCondition(key=key, match=qm.MatchValue(value=value)))
if not body.include_superseded:
must.append(qm.IsEmptyCondition(is_empty=qm.PayloadField(key="superseded_by")))
must_not: list[Any] = []
if not body.include_private:
# default: esclude i record riservati (private=true) dalle ricerche standard
must_not.append(qm.FieldCondition(key="private", match=qm.MatchValue(value=True)))
if must or must_not:
return qm.Filter(must=must or None, must_not=must_not or None)
return None
def search(qdrant: Any, collection: str, body: SearchIn, vector: list[float], sparse: Any, limit: Optional[int] = None) -> list[Any]:
"""Ricerca ibrida o densa. Con reranking attivo limit > top_k per dare candidati extra allo stadio di rerank."""
eff_limit = limit if limit is not None else body.top_k
qfilter = search_filter(body)
if body.hybrid and sparse is not None:
return qdrant.query_points(
collection_name=collection,
prefetch=[
qm.Prefetch(query=vector, using="", limit=max(body.top_k * 4, eff_limit), score_threshold=body.min_score),
qm.Prefetch(query=sparse, using=SPARSE_VECTOR_NAME, limit=max(body.top_k * 4, eff_limit)),
],
query=qm.FusionQuery(fusion=qm.Fusion.RRF),
query_filter=qfilter,
limit=eff_limit,
with_payload=True,
).points
return qdrant.query_points(
collection_name=collection,
query=vector,
query_filter=qfilter,
limit=eff_limit,
score_threshold=body.min_score,
with_payload=True,
).points
def format_results(hits: list[Any]) -> list[dict]:
return [
{
"memory_id": h.id,
"score": round(h.score, 4),
"text": h.payload.get("text"),
"kind": h.payload.get("kind"),
"agent_id": h.payload.get("agent_id"),
"scope": h.payload.get("scope"),
"project_id": h.payload.get("project_id"),
"confidence": h.payload.get("confidence"),
"importance": h.payload.get("importance", 0.5),
"created_at": h.payload.get("created_at"),
"source": h.payload.get("source"),
"supersedes_id": h.payload.get("supersedes_id"),
"superseded_by": h.payload.get("superseded_by"),
"supersede_reason": h.payload.get("supersede_reason"),
"parent_id": h.payload.get("parent_id"),
"level": h.payload.get("level"),
"topic": h.payload.get("topic"),
"private": h.payload.get("private", False),
"links": h.payload.get("links"),
}
for h in hits
]
def reparent_active_children(qdrant: Any, collection: str, old_id: str, new_id: str) -> int:
children, _ = qdrant.scroll(
collection_name=collection,
scroll_filter=qm.Filter(must=[
qm.FieldCondition(key="parent_id", match=qm.MatchValue(value=old_id)),
qm.IsEmptyCondition(is_empty=qm.PayloadField(key="superseded_by")),
]),
limit=1000,
with_payload=False,
)
if not children:
return 0
qdrant.set_payload(collection_name=collection, payload={"parent_id": new_id}, points=[p.id for p in children])
return len(children)
-154
View File
@@ -1,154 +0,0 @@
"""Fixtures pytest per il Memory Gateway: FakeQdrant in-memory + mock di embed.
I test non richiedono Qdrant Ollama: il gateway viene importato con le
dipendenze reali (fastapi/pydantic/qdrant-client/httpx) ma qdrant e embed
sono sostituiti da fake deterministici.
"""
import os
import sys
from pathlib import Path
# Disabilita il push metriche nei test
os.environ["METRICS_ENABLED"] = "false"
os.environ["API_KEYS"] = "test-key"
# Guardrail disabilitato di default nei test esistenti (abilitato nei test del guardrail)
os.environ["GUARDRAIL_ENABLED"] = "false"
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import pytest # noqa: E402
import main as gateway # noqa: E402
class FakePoint:
def __init__(self, point_id, vector=None, payload=None):
self.id = point_id
self.vector = vector or {}
self.payload = payload or {}
class FakeQdrant:
"""Implementazione in-memory dei metodi Qdrant usati dal gateway."""
def __init__(self):
self.points: dict[str, FakePoint] = {}
self.collection_exists = False
self.upsert_calls = 0
self.query_score = 0.9 # score di default per query_points (configurabile nei test)
def get_collections(self):
class _C:
def __init__(self, names):
self.collections = [type("X", (), {"name": n})() for n in names]
return _C(["memories"] if self.collection_exists else [])
def create_collection(self, **kw):
self.collection_exists = True
def create_payload_index(self, **kw):
pass
def create_vector_name(self, *a, **kw):
pass
def upsert(self, collection_name, points, **kw):
self.upsert_calls += 1
for p in points:
self.points[p.id] = FakePoint(p.id, p.vector, p.payload)
def retrieve(self, collection_name, ids, with_payload=True):
return [self.points[i] for i in ids if i in self.points]
def set_payload(self, collection_name, payload, points, **kw):
for pid in points:
if pid in self.points:
self.points[pid].payload.update(payload)
def delete(self, collection_name, points_selector, **kw):
for pid in points_selector:
self.points.pop(pid, None)
def _matches(self, pl, query_filter):
"""Applica i filtri metadata (FieldCondition match / IsEmptyCondition)."""
if not query_filter or not query_filter.must:
return True
for cond in query_filter.must:
if hasattr(cond, "key") and hasattr(cond, "match"):
if pl.get(cond.key) != cond.match.value:
return False
elif hasattr(cond, "is_empty"):
if pl.get(cond.is_empty.key):
return False
return True
def scroll(self, collection_name, scroll_filter=None, limit=None, with_payload=True, **kw):
points = [p for p in self.points.values() if self._matches(p.payload, scroll_filter)]
if limit:
points = points[:limit]
return points, None
def get_collection(self, collection_name):
class _Info:
points_count = len(self.points)
return _Info()
def query_points(self, collection_name, query=None, query_filter=None, limit=5,
score_threshold=None, with_payload=True, prefetch=None, **kw):
# Ritorna tutti i punti (score fisso); i filtri metadata sono applicati
# in modo semplice per testare kind/project_id/scope/superseded.
results = []
for p in self.points.values():
pl = p.payload
if query_filter and query_filter.must:
ok = True
for cond in query_filter.must:
if hasattr(cond, "key") and hasattr(cond, "match"):
if pl.get(cond.key) != cond.match.value:
ok = False
elif hasattr(cond, "is_empty"):
if pl.get(cond.is_empty.key):
ok = False
if not ok:
continue
results.append(type("H", (), {"id": p.id, "score": self.query_score, "payload": pl})())
return type("R", (), {"points": results[:limit]})()
@pytest.fixture
def client(monkeypatch):
"""TestClient con qdrant e embed finti."""
fake = FakeQdrant()
monkeypatch.setattr(gateway.state, "qdrant", fake)
monkeypatch.setattr(gateway, "qdrant", fake)
monkeypatch.setattr(gateway, "API_KEYS", {"test-key"}, raising=False)
monkeypatch.setattr(gateway.state, "ratelimit", {}) # rate limit pulito per test
async def fake_embed(text):
return [0.0] * 1024
monkeypatch.setattr(gateway.state, "embed", fake_embed)
monkeypatch.setattr(gateway, "embed", fake_embed)
from fastapi.testclient import TestClient
with TestClient(gateway.app) as c:
c.fake_qdrant = fake
yield c
def auth_headers():
return {"X-API-Key": "test-key"}
def make_record(**overrides):
base = {
"text": "record di test",
"kind": "fact",
"project_id": "test-proj",
"scope": "agent",
}
base.update(overrides)
return base
-399
View File
@@ -1,399 +0,0 @@
"""Test API del Memory Gateway: validazione, auth, idempotency, supersede, ricerca."""
import pytest
from conftest import auth_headers, make_record
# ---------------------------------------------------------------------------
# Validazione input
# ---------------------------------------------------------------------------
def test_project_id_obbligatorio(client):
body = make_record()
del body["project_id"]
r = client.post("/v1/memories", json=body, headers=auth_headers())
assert r.status_code == 422
def test_kind_invalido(client):
r = client.post("/v1/memories", json=make_record(kind="boh"), headers=auth_headers())
assert r.status_code == 422
def test_expires_at_invalido(client):
r = client.post("/v1/memories", json=make_record(expires_at="non-una-data"), headers=auth_headers())
assert r.status_code == 422
assert "ISO 8601" in r.text
def test_expires_at_valido(client):
r = client.post("/v1/memories", json=make_record(expires_at="2026-09-01T00:00:00Z"), headers=auth_headers())
assert r.status_code == 200
def test_confidence_invalido(client):
r = client.post("/v1/memories", json=make_record(confidence="super"), headers=auth_headers())
assert r.status_code == 422
def test_confidence_default_medium(client):
r = client.post("/v1/memories", json=make_record(), headers=auth_headers())
assert r.status_code == 200
mid = r.json()["memory_id"]
g = client.get(f"/v1/memories/{mid}", headers=auth_headers())
assert g.json()["confidence"] == "medium"
def test_text_troppo_lungo(client):
r = client.post("/v1/memories", json=make_record(text="x" * 9000), headers=auth_headers())
assert r.status_code == 422
# ---------------------------------------------------------------------------
# Auth e rate limit
# ---------------------------------------------------------------------------
def test_senza_chiave_422(client):
# Header X-API-Key mancante → 422 (header richiesto da FastAPI)
r = client.post("/v1/memories", json=make_record())
assert r.status_code == 422
def test_chiave_invalida_401(client):
r = client.post("/v1/memories", json=make_record(), headers={"X-API-Key": "sbagliata"})
assert r.status_code == 401
def test_rate_limit_429(client, monkeypatch):
import config
monkeypatch.setattr(config, "RATE_LIMIT_PER_MIN", 3)
for _ in range(3):
r = client.post("/v1/memories", json=make_record(), headers=auth_headers())
assert r.status_code == 200
r = client.post("/v1/memories", json=make_record(), headers=auth_headers())
assert r.status_code == 429
# ---------------------------------------------------------------------------
# Idempotency
# ---------------------------------------------------------------------------
def test_idempotency_replay_stessa_risposta(client):
h = {**auth_headers(), "Idempotency-Key": "k-1"}
r1 = client.post("/v1/memories", json=make_record(), headers=h)
r2 = client.post("/v1/memories", json=make_record(), headers=h)
assert r1.status_code == 200 and r2.status_code == 200
assert r1.json()["memory_id"] == r2.json()["memory_id"]
assert client.fake_qdrant.upsert_calls == 1
def test_idempotency_payload_diverso_409(client):
h = {**auth_headers(), "Idempotency-Key": "k-2"}
client.post("/v1/memories", json=make_record(), headers=h)
r = client.post("/v1/memories", json=make_record(text="diverso"), headers=h)
assert r.status_code == 409
def test_idempotency_key_diverse_record_distinti(client):
r1 = client.post("/v1/memories", json=make_record(), headers={**auth_headers(), "Idempotency-Key": "k-a"})
r2 = client.post("/v1/memories", json=make_record(), headers={**auth_headers(), "Idempotency-Key": "k-b"})
assert r1.json()["memory_id"] != r2.json()["memory_id"]
# ---------------------------------------------------------------------------
# Supersede
# ---------------------------------------------------------------------------
def test_supersede_target_inesistente_404(client):
r = client.post(
"/v1/memories",
json=make_record(supersedes_id="00000000-0000-0000-0000-000000000000"),
headers=auth_headers(),
)
assert r.status_code == 404
def test_supersede_ok_e_lineage(client):
r1 = client.post("/v1/memories", json=make_record(text="fatto falso"), headers=auth_headers())
old_id = r1.json()["memory_id"]
r2 = client.post(
"/v1/memories",
json=make_record(text="fatto corretto", supersedes_id=old_id, supersede_reason="evidenza"),
headers=auth_headers(),
)
assert r2.status_code == 200
new_id = r2.json()["memory_id"]
# il vecchio è marcato superseded_by
old = client.get(f"/v1/memories/{old_id}", headers=auth_headers()).json()
assert old["superseded_by"] == new_id
# la ricerca di default esclude i superseduti
s = client.post("/v1/memories:search", json={"query": "fatto", "top_k": 10, "min_score": 0.0}, headers=auth_headers())
ids = [x["memory_id"] for x in s.json()["results"]]
assert old_id not in ids
# include_superseded li mostra
s2 = client.post(
"/v1/memories:search",
json={"query": "fatto", "top_k": 10, "min_score": 0.0, "include_superseded": True},
headers=auth_headers(),
)
ids2 = [x["memory_id"] for x in s2.json()["results"]]
assert old_id in ids2
def test_supersede_doppio_409(client):
r1 = client.post("/v1/memories", json=make_record(text="falso"), headers=auth_headers())
old_id = r1.json()["memory_id"]
client.post("/v1/memories", json=make_record(text="corretto", supersedes_id=old_id), headers=auth_headers())
r = client.post("/v1/memories", json=make_record(text="ancora", supersedes_id=old_id), headers=auth_headers())
assert r.status_code == 409
def test_supersede_root_ri_parenta_figli_attivi(client):
# L1 root + figlio L2
r1 = client.post(
"/v1/memories",
json=make_record(text="root L1", level="L1_ROOT", topic="TEST-TOPIC/ROOT"),
headers=auth_headers(),
)
root_id = r1.json()["memory_id"]
r2 = client.post(
"/v1/memories",
json=make_record(text="figlio L2", level="L2_SUBTOPIC", topic="TEST-TOPIC/SUB", parent_id=root_id),
headers=auth_headers(),
)
child_id = r2.json()["memory_id"]
# supersede il root
r3 = client.post(
"/v1/memories",
json=make_record(
text="root L1 corretto",
level="L1_ROOT",
topic="TEST-TOPIC/ROOT",
supersedes_id=root_id,
supersede_reason="aggiornamento",
),
headers=auth_headers(),
)
assert r3.status_code == 200
new_root_id = r3.json()["memory_id"]
assert r3.json()["reparented"] == 1
# il figlio attivo ora punta al nuovo root
child = client.get(f"/v1/memories/{child_id}", headers=auth_headers()).json()
assert child["parent_id"] == new_root_id
# search per parent_id sul nuovo root trova il figlio
s = client.post(
"/v1/memories:search",
json={"query": "*", "parent_id": new_root_id, "top_k": 10, "min_score": 0.0},
headers=auth_headers(),
)
ids = [x["memory_id"] for x in s.json()["results"]]
assert child_id in ids
def test_supersede_root_ri_parenta_solo_figli_attivi(client):
# L1 root + figlio L2 + figlio L2 già superseduto (versione attiva C1')
r1 = client.post(
"/v1/memories",
json=make_record(text="root", level="L1_ROOT", topic="T2/ROOT"),
headers=auth_headers(),
)
root_id = r1.json()["memory_id"]
c1 = client.post(
"/v1/memories",
json=make_record(text="figlio vecchio", level="L2_SUBTOPIC", topic="T2/SUB", parent_id=root_id),
headers=auth_headers(),
)
c1_id = c1.json()["memory_id"]
c1p = client.post(
"/v1/memories",
json=make_record(
text="figlio nuovo",
level="L2_SUBTOPIC",
topic="T2/SUB",
parent_id=root_id,
supersedes_id=c1_id,
),
headers=auth_headers(),
)
c1p_id = c1p.json()["memory_id"]
r2 = client.post(
"/v1/memories",
json=make_record(text="root corretto", level="L1_ROOT", topic="T2/ROOT", supersedes_id=root_id),
headers=auth_headers(),
)
new_root_id = r2.json()["memory_id"]
assert r2.json()["reparented"] == 1 # solo C1' (attivo)
# C1' ri-parentato al nuovo root; C1 storico resta ancorato al vecchio
assert client.get(f"/v1/memories/{c1p_id}", headers=auth_headers()).json()["parent_id"] == new_root_id
assert client.get(f"/v1/memories/{c1_id}", headers=auth_headers()).json()["parent_id"] == root_id
# ---------------------------------------------------------------------------
# Ricerca e filtri
# ---------------------------------------------------------------------------
def test_search_filtro_project_id(client):
client.post("/v1/memories", json=make_record(text="uno", project_id="proj-a"), headers=auth_headers())
client.post("/v1/memories", json=make_record(text="due", project_id="proj-b"), headers=auth_headers())
s = client.post(
"/v1/memories:search",
json={"query": "test", "project_id": "proj-a", "top_k": 10, "min_score": 0.0},
headers=auth_headers(),
)
results = s.json()["results"]
assert len(results) == 1
assert results[0]["project_id"] == "proj-a"
def test_search_hybrid_param_accettato(client):
client.post("/v1/memories", json=make_record(text="codice XYZ-123"), headers=auth_headers())
s = client.post(
"/v1/memories:search",
json={"query": "XYZ-123", "top_k": 5, "min_score": 0.0, "hybrid": True},
headers=auth_headers(),
)
assert s.status_code == 200
assert "results" in s.json()
def test_meta_overview(client):
client.post("/v1/memories", json=make_record(project_id="proj-a"), headers=auth_headers())
r = client.get("/v1/meta/overview", headers=auth_headers())
assert r.status_code == 200
data = r.json()
assert data["total"] >= 1
assert any(p["project_id"] == "proj-a" for p in data["projects"])
def test_status_pubblico(client):
r = client.get("/v1/status")
assert r.status_code == 200
assert r.json()["status"] == "ok"
def test_metrics_auth(client):
# Header mancante → 422; chiave invalida → 401; chiave valida → 200
assert client.get("/v1/metrics").status_code == 422
assert client.get("/v1/metrics", headers={"X-API-Key": "sbagliata"}).status_code == 401
r2 = client.get("/v1/metrics", headers=auth_headers())
assert r2.status_code == 200
assert "requests" in r2.json()
# ---------------------------------------------------------------------------
# Versione / metadata del codice
# ---------------------------------------------------------------------------
def test_version_endpoint_pubblico(client):
"""GET /v1/version è pubblico e espone git_commit e guardrail_version."""
r = client.get("/v1/version")
assert r.status_code == 200
data = r.json()
assert "git_commit" in data
assert "version" in data
assert "guardrail_version" in data
assert data["guardrail_version"] == "similarity-v2"
def test_status_espone_git_commit(client):
"""/v1/status include version, git_commit e guardrail_version."""
r = client.get("/v1/status")
assert r.status_code == 200
data = r.json()
assert "git_commit" in data
assert "version" in data
assert "guardrail_version" in data
# ---------------------------------------------------------------------------
# Struttura Gerarchica e Relazionale
# ---------------------------------------------------------------------------
def test_create_and_retrieve_hierarchical_record(client):
"""Crea un nodo Root L1 e un nodo Figlio L2 con links, parent_id, level, topic."""
# 1. Crea Root L1
r_root = client.post(
"/v1/memories",
json=make_record(
text="Master Topic Alfa Romeo",
level="L1_ROOT",
topic="ALFA-ROMEO/ROOT",
),
headers=auth_headers(),
)
assert r_root.status_code == 200
root_id = r_root.json()["memory_id"]
# 2. Crea Figlio L2 collegato
r_child = client.post(
"/v1/memories",
json=make_record(
text="Scheda Tecnica Bialbero 1.3",
parent_id=root_id,
level="L2_SUBTOPIC",
topic="ALFA-ROMEO/SPECS",
links=[{"target_id": root_id, "predicate": "part_of", "weight": 1.0}],
),
headers=auth_headers(),
)
assert r_child.status_code == 200
child_id = r_child.json()["memory_id"]
# 3. Recupera e verifica payload strutturato
g = client.get(f"/v1/memories/{child_id}", headers=auth_headers())
assert g.status_code == 200
data = g.json()
assert data["parent_id"] == root_id
assert data["level"] == "L2_SUBTOPIC"
assert data["topic"] == "ALFA-ROMEO/SPECS"
assert len(data["links"]) == 1
assert data["links"][0]["target_id"] == root_id
def test_search_filters_hierarchical(client):
"""Filtra per parent_id, level e topic."""
r_root = client.post(
"/v1/memories",
json=make_record(text="Root doc", level="L1_ROOT", topic="TOPIC/ROOT"),
headers=auth_headers(),
)
root_id = r_root.json()["memory_id"]
client.post(
"/v1/memories",
json=make_record(text="Child A", parent_id=root_id, level="L2_SUBTOPIC", topic="TOPIC/A"),
headers=auth_headers(),
)
client.post(
"/v1/memories",
json=make_record(text="Child B", parent_id=root_id, level="L2_SUBTOPIC", topic="TOPIC/B"),
headers=auth_headers(),
)
# Cerca solo L1_ROOT
s1 = client.post(
"/v1/memories:search",
json={"query": "doc", "level": "L1_ROOT", "top_k": 5, "min_score": 0.0},
headers=auth_headers(),
)
assert s1.status_code == 200
assert len(s1.json()["results"]) == 1
assert s1.json()["results"][0]["level"] == "L1_ROOT"
# Cerca per parent_id
s2 = client.post(
"/v1/memories:search",
json={"query": "Child", "parent_id": root_id, "top_k": 5, "min_score": 0.0},
headers=auth_headers(),
)
assert s2.status_code == 200
assert len(s2.json()["results"]) == 2
# Cerca per topic specifico
s3 = client.post(
"/v1/memories:search",
json={"query": "Child", "topic": "TOPIC/A", "top_k": 5, "min_score": 0.0},
headers=auth_headers(),
)
assert s3.status_code == 200
assert len(s3.json()["results"]) == 1
assert s3.json()["results"][0]["topic"] == "TOPIC/A"
-189
View File
@@ -1,189 +0,0 @@
"""Test della catena di fallback per gli embedding e del wrapper Qdrant resilient."""
from __future__ import annotations
import asyncio
import json
import httpx
import pytest
import embed as embed_mod
import state
from test_api import auth_headers, make_record
CHAIN = json.dumps(
[
{"name": "primario", "url": "http://primario:9001", "api": "llamacpp", "key": "k1", "timeout_ms": 500},
{"name": "fallback", "url": "http://fallback:9002", "api": "ollama", "key": "k2", "timeout_ms": 5000},
]
)
VEC_1024 = [0.01] * 1024
@pytest.fixture(autouse=True)
def _reset_chain(monkeypatch):
embed_mod.reset_chain_cache()
embed_mod._http = None
yield
embed_mod.reset_chain_cache()
embed_mod._http = None
def _mock_client(handler) -> list[str]:
calls: list[str] = []
def tracking_handler(request):
calls.append(f"{request.url.host}{request.url.path}")
return handler(request)
embed_mod._http = httpx.AsyncClient(transport=httpx.MockTransport(tracking_handler))
return calls
# ---------------------------------------------------------------------------
# parse della catena
# ---------------------------------------------------------------------------
def test_parse_chain_valida(monkeypatch):
monkeypatch.setattr(embed_mod, "EMBED_CHAIN", CHAIN)
embed_mod.reset_chain_cache()
nodes = embed_mod.chain_nodes()
assert [n.name for n in nodes] == ["primario", "fallback"]
assert [n.api for n in nodes] == ["llamacpp", "ollama"]
def test_parse_legacy_quando_catena_vuota(monkeypatch):
monkeypatch.setattr(embed_mod, "EMBED_CHAIN", "")
monkeypatch.setattr(embed_mod, "EMBED_API", "ollama")
monkeypatch.setattr(embed_mod, "EMBED_URL", "http://legacy:11434")
monkeypatch.setattr(embed_mod, "EMBED_API_KEY", "lk")
embed_mod.reset_chain_cache()
nodes = embed_mod.chain_nodes()
assert len(nodes) == 1
assert nodes[0].name == "embed"
assert nodes[0].api == "ollama"
assert nodes[0].url == "http://legacy:11434"
assert nodes[0].key == "lk"
def test_parse_chain_json_invalido_cade_su_legacy(monkeypatch):
monkeypatch.setattr(embed_mod, "EMBED_CHAIN", "non-json")
monkeypatch.setattr(embed_mod, "EMBED_URL", "http://legacy:11434")
embed_mod.reset_chain_cache()
assert [n.url for n in embed_mod.chain_nodes()] == ["http://legacy:11434"]
# ---------------------------------------------------------------------------
# fallback e cooldown
# ---------------------------------------------------------------------------
def test_fallback_primario_llamacpp_fallito(monkeypatch):
monkeypatch.setattr(embed_mod, "EMBED_CHAIN", CHAIN)
embed_mod.reset_chain_cache()
def handler(request):
if request.url.host == "primario":
return httpx.Response(500)
# nodo ollama-style: risposta con campo "embeddings"
return httpx.Response(200, json={"embeddings": [VEC_1024]})
embed_mod._http = httpx.AsyncClient(transport=httpx.MockTransport(handler))
vector = asyncio.run(embed_mod.embed("test"))
assert vector == VEC_1024
def test_cooldown_salta_primario(monkeypatch):
monkeypatch.setattr(embed_mod, "EMBED_CHAIN", CHAIN)
embed_mod.reset_chain_cache()
calls: list[str] = []
def tracking(request):
calls.append(request.url.host)
if request.url.host == "primario":
return httpx.Response(500)
return httpx.Response(200, json={"embeddings": [VEC_1024]})
embed_mod._http = httpx.AsyncClient(transport=httpx.MockTransport(tracking))
asyncio.run(embed_mod.embed("test"))
asyncio.run(embed_mod.embed("test"))
# il primario fallito entra in cooldown: la seconda chiamata lo salta
assert calls == ["primario", "fallback", "fallback"]
def test_dimensione_errata_salta_nodo(monkeypatch):
monkeypatch.setattr(embed_mod, "EMBED_CHAIN", CHAIN)
embed_mod.reset_chain_cache()
def handler(request):
if request.url.host == "primario":
return httpx.Response(200, json={"data": [{"embedding": [0.0] * 512}]}) # dim sbagliata
return httpx.Response(200, json={"embeddings": [VEC_1024]})
embed_mod._http = httpx.AsyncClient(transport=httpx.MockTransport(handler))
vector = asyncio.run(embed_mod.embed("test"))
assert len(vector) == 1024
def test_tutti_nodi_falliti_rilancia(monkeypatch):
monkeypatch.setattr(embed_mod, "EMBED_CHAIN", CHAIN)
embed_mod.reset_chain_cache()
embed_mod._http = httpx.AsyncClient(transport=httpx.MockTransport(lambda r: httpx.Response(500)))
with pytest.raises(RuntimeError, match="tutti i nodi embedding falliti"):
asyncio.run(embed_mod.embed("test"))
def test_empty_text_comunque_chiamata(monkeypatch):
# il modello pydantic valida già la query; qui verifichiamo il passthrough
monkeypatch.setattr(embed_mod, "EMBED_CHAIN", CHAIN)
embed_mod.reset_chain_cache()
def handler(request):
body = json.loads(request.content)
return httpx.Response(200, json={"embeddings": [VEC_1024] if body["input"] else []})
embed_mod._http = httpx.AsyncClient(transport=httpx.MockTransport(handler))
assert asyncio.run(embed_mod.embed("ok")) == VEC_1024
# ---------------------------------------------------------------------------
# ResilientQdrant (retry transiente)
# ---------------------------------------------------------------------------
class FlakyQdrant:
def __init__(self, failures: int, exc: Exception):
self.calls = 0
self.failures = failures
self.exc = exc
def upsert(self, **kw):
self.calls += 1
if self.calls <= self.failures:
raise self.exc
return "ok"
def test_retry_su_errore_transiente(monkeypatch):
fake = FlakyQdrant(2, httpx.ConnectError("conn"))
client = state.ResilientQdrant(fake, attempts=3, backoff_s=0.01)
assert client.upsert(x=1) == "ok"
assert fake.calls == 3
def test_niente_retry_su_errore_applicativo():
fake = FlakyQdrant(2, ValueError("404 logico"))
client = state.ResilientQdrant(fake, attempts=3, backoff_s=0.01)
with pytest.raises(ValueError):
client.upsert(x=1)
assert fake.calls == 1
def test_retry_esaurito_rilancia():
fake = FlakyQdrant(99, httpx.ReadTimeout("t"))
client = state.ResilientQdrant(fake, attempts=2, backoff_s=0.01)
with pytest.raises(httpx.ReadTimeout):
client.upsert(x=1)
assert fake.calls == 2
-111
View File
@@ -1,111 +0,0 @@
"""Test del guardrail di similarità pre-scrittura (Memory Gateway).
Casi: duplicato esatto (hash) -> BLOCK 409; similarità alta -> BLOCK 409;
similarità moderata -> WARN (salva con flag); nessun candidato -> ALLOW;
supersede esplicito bypassa il guardrail.
"""
import pytest
from conftest import auth_headers, make_record
@pytest.fixture(autouse=True)
def enable_guardrail(monkeypatch):
"""Abilita il guardrail per i test di questa suite (il conftest lo disabilita di default)."""
import config
monkeypatch.setattr(config, "GUARDRAIL_ENABLED", True)
def test_duplicato_esatto_bloccato_409(client):
"""Stesso testo normalizzato -> hash uguale -> BLOCK (409)."""
body = make_record(text="Il cliente non accede al portale COSMO-SkyMed")
r1 = client.post("/v1/memories", json=body, headers=auth_headers())
assert r1.status_code == 200
# Stesso testo con maiuscole/spazi diversi -> stesso hash normalizzato
body2 = make_record(text=" IL CLIENTE NON ACCEDE al portale COSMO-SkyMed ")
r2 = client.post("/v1/memories", json=body2, headers=auth_headers())
assert r2.status_code == 409
detail = r2.json()["detail"]
assert detail["error"] == "duplicate_memory"
assert detail["reason"] == "EXACT_DUPLICATE"
def test_similarita_alta_bloccata_409(client):
"""Score top-1 >= soglia BLOCK (0.85) -> 409 KNOWN_SOLUTION."""
client.fake_qdrant.query_score = 0.9
r1 = client.post("/v1/memories", json=make_record(text="primo record"), headers=auth_headers())
assert r1.status_code == 200
r2 = client.post("/v1/memories", json=make_record(text="secondo record simile"), headers=auth_headers())
assert r2.status_code == 409
assert r2.json()["detail"]["reason"] == "KNOWN_SOLUTION"
def test_similarita_moderata_warn_salva(client):
"""Score top-1 tra 0.70 e 0.85 -> WARN: salva con flag guardrail."""
client.fake_qdrant.query_score = 0.75
r1 = client.post("/v1/memories", json=make_record(text="primo record"), headers=auth_headers())
assert r1.status_code == 200
r2 = client.post("/v1/memories", json=make_record(text="secondo record simile"), headers=auth_headers())
assert r2.status_code == 200
memory_id = r2.json()["memory_id"]
# Il record salvato deve avere il flag guardrail WARN
saved = client.fake_qdrant.points[memory_id].payload
assert saved["guardrail"]["decision"] == "WARN"
assert saved["guardrail"]["reason"] == "MODERATE_SIMILARITY"
def test_nessun_candidato_allow(client):
"""Score top-1 sotto soglia WARN -> ALLOW, nessun flag guardrail."""
client.fake_qdrant.query_score = 0.5
r1 = client.post("/v1/memories", json=make_record(text="primo record"), headers=auth_headers())
assert r1.status_code == 200
r2 = client.post("/v1/memories", json=make_record(text="secondo record"), headers=auth_headers())
assert r2.status_code == 200
memory_id = r2.json()["memory_id"]
saved = client.fake_qdrant.points[memory_id].payload
assert saved["guardrail"]["decision"] == "ALLOW"
assert saved["guardrail"]["reason"] == "NEW_SOLUTION"
def test_supersede_bypassa_guardrail(client):
"""Il supersede esplicito è una correzione intenzionale: bypassa il guardrail."""
client.fake_qdrant.query_score = 0.9
r1 = client.post("/v1/memories", json=make_record(text="record originale"), headers=auth_headers())
assert r1.status_code == 200
old_id = r1.json()["memory_id"]
# Supersede con testo molto simile -> deve passare (correzione intenzionale)
r2 = client.post(
"/v1/memories",
json=make_record(text="record originale corretto", supersedes_id=old_id, supersede_reason="correzione"),
headers=auth_headers(),
)
assert r2.status_code == 200
assert r2.json()["supersedes_id"] == old_id
def test_guardrail_disabilitato_salva_sempre(client, monkeypatch):
"""Con GUARDRAIL_ENABLED=false non si blocca nulla."""
import config
monkeypatch.setattr(config, "GUARDRAIL_ENABLED", False)
client.fake_qdrant.query_score = 0.9
r1 = client.post("/v1/memories", json=make_record(text="primo"), headers=auth_headers())
assert r1.status_code == 200
r2 = client.post("/v1/memories", json=make_record(text="primo"), headers=auth_headers())
assert r2.status_code == 200
def test_text_hash_salvato_nel_payload(client):
"""Ogni record salvato deve avere text_hash (per lo strato 1 del guardrail)."""
r = client.post("/v1/memories", json=make_record(text="record con hash"), headers=auth_headers())
assert r.status_code == 200
memory_id = r.json()["memory_id"]
saved = client.fake_qdrant.points[memory_id].payload
assert "text_hash" in saved
assert len(saved["text_hash"]) == 64 # SHA-256 hex
-219
View File
@@ -1,219 +0,0 @@
"""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
def test_troncamento_documenti(monkeypatch):
_use_chain(monkeypatch)
seen: dict = {}
def handler(request):
seen["docs"] = json.loads(request.content)["documents"]
return httpx.Response(200, json={"results": [{"index": 0, "relevance_score": 1.0}]})
_mock_client(handler)
asyncio.run(rerank.rerank("q", ["x" * 5000, "corto"]))
assert len(seen["docs"][0]) == 800 # default RERANK_MAX_DOC_CHARS
assert seen["docs"][1] == "corto"
# ---------------------------------------------------------------------------
# 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
-252
View File
@@ -1,252 +0,0 @@
"""Test strategie rerank oltre la search: gate store (A), supersede verify (B),
score composito (C), multi-query (E), primitiva /v1/score."""
from __future__ import annotations
import pytest
from conftest import auth_headers, make_record
@pytest.fixture(autouse=True)
def enable_guardrail(monkeypatch):
import config
monkeypatch.setattr(config, "GUARDRAIL_ENABLED", True)
def _patch_rerank(monkeypatch, scores, backend="finto", took=10):
"""Abilita il rerank anche nel guardrail (che importa i valori da config)."""
monkeypatch.setattr("rerank.enabled", lambda: True)
monkeypatch.setattr("guardrail.GUARDRAIL_RERANK", True)
recorded: dict = {}
async def fake_rerank(query, docs):
recorded["query"] = query
recorded["docs"] = list(docs)
return scores, backend, took
monkeypatch.setattr("rerank.rerank", fake_rerank)
return recorded
# ---------------------------------------------------------------------------
# A: gate store con cross-encoder
# ---------------------------------------------------------------------------
def test_cosine_alto_cross_basso_downgrade_a_warn(client, monkeypatch):
"""Cosine 0.9 (zona BLOCK) ma cross-score basso → WARN CROSS_DUP_WEAK, salva."""
_patch_rerank(monkeypatch, scores=[-6.0]) # sigmoid ≈ 0.0024
client.fake_qdrant.query_score = 0.9
client.post("/v1/memories", json=make_record(text="primo record"), headers=auth_headers())
r2 = client.post("/v1/memories", json=make_record(text="secondo simile"), headers=auth_headers())
assert r2.status_code == 200
saved = client.fake_qdrant.points[r2.json()["memory_id"]].payload
assert saved["guardrail"]["decision"] == "WARN"
assert saved["guardrail"]["reason"] == "CROSS_DUP_WEAK"
def test_cosine_warn_cross_alto_upgrade_a_block(client, monkeypatch):
"""Cosine in zona WARN (0.75) ma cross-score altissimo → BLOCK parafrasato catturato."""
import rerank as rr_mod
monkeypatch.setattr("guardrail.GUARDRAIL_RERANK", True)
monkeypatch.setattr("rerank.enabled", lambda: True)
async def fake_rerank(query, docs):
return [3.0], "finto", 5 # sigmoid ≈ 0.953 ≥ 0.88
monkeypatch.setattr("rerank.rerank", fake_rerank)
client.fake_qdrant.query_score = 0.75
client.post("/v1/memories", json=make_record(text="primo record"), headers=auth_headers())
r2 = client.post("/v1/memories", json=make_record(text="stesso fatto riformulato"), headers=auth_headers())
assert r2.status_code == 409
assert r2.json()["detail"]["reason"] == "CROSS_DUP_CONFIRMED"
def test_cosine_basso_cross_alto_block_low_cosine(client, monkeypatch):
"""Cosine sotto soglia WARN (0.5) ma cross altissimo → CROSS_DUP_LOW_COSINE."""
monkeypatch.setattr("guardrail.GUARDRAIL_RERANK", True)
monkeypatch.setattr("rerank.enabled", lambda: True)
async def fake_rerank(query, docs):
return [4.0], "finto", 5 # sigmoid ≈ 0.982
monkeypatch.setattr("rerank.rerank", fake_rerank)
client.fake_qdrant.query_score = 0.5
client.post("/v1/memories", json=make_record(text="primo record"), headers=auth_headers())
r2 = client.post("/v1/memories", json=make_record(text="secondo riformulato"), headers=auth_headers())
assert r2.status_code == 409
assert r2.json()["detail"]["reason"] == "CROSS_DUP_LOW_COSINE"
def test_warn_con_suggerimento_supersedes(client, monkeypatch):
"""Cosine in zona WARN, cross ≥ soglia suggest → WARN con suggestion.supersedes_id."""
monkeypatch.setattr("guardrail.GUARDRAIL_RERANK", True)
monkeypatch.setattr("rerank.enabled", lambda: True)
async def fake_rerank(query, docs):
return [1.8], "finto", 5 # sigmoid ≈ 0.858: ≥ 0.85 (suggest), < 0.90 (block)
monkeypatch.setattr("rerank.rerank", fake_rerank)
client.fake_qdrant.query_score = 0.75
r1 = client.post("/v1/memories", json=make_record(text="primo record"), headers=auth_headers())
old_id = r1.json()["memory_id"]
r2 = client.post("/v1/memories", json=make_record(text="secondo simile"), headers=auth_headers())
assert r2.status_code == 200
saved = client.fake_qdrant.points[r2.json()["memory_id"]].payload
assert saved["guardrail"]["reason"] == "MODERATE_SIMILARITY"
assert saved["guardrail"]["suggestion"]["supersedes_id"] == old_id
def test_rerank_giu_degrada_a_solo_cosine(client, monkeypatch):
"""Reranker irraggiungibile → decisione legacy per cosine (BLOCK a 0.9)."""
monkeypatch.setattr("guardrail.GUARDRAIL_RERANK", True)
monkeypatch.setattr("rerank.enabled", lambda: True)
async def fail_rerank(query, docs):
return None
monkeypatch.setattr("rerank.rerank", fail_rerank)
client.fake_qdrant.query_score = 0.9
client.post("/v1/memories", json=make_record(text="primo record"), headers=auth_headers())
r2 = client.post("/v1/memories", json=make_record(text="secondo simile"), headers=auth_headers())
assert r2.status_code == 409
assert r2.json()["detail"]["reason"] == "KNOWN_SOLUTION"
# ---------------------------------------------------------------------------
# B: verifica supersede
# ---------------------------------------------------------------------------
def test_supersede_cross_basso_warning(client, monkeypatch):
monkeypatch.setattr("config.GUARDRAIL_SUPERSEDE_CHECK", True)
monkeypatch.setattr("rerank.enabled", lambda: True)
async def fake_rerank(query, docs):
return [-8.0], "finto", 5 # sigmoid ≈ 0.0003 < 0.50
monkeypatch.setattr("rerank.rerank", fake_rerank)
r1 = client.post("/v1/memories", json=make_record(text="record originale"), headers=auth_headers())
old_id = r1.json()["memory_id"]
r2 = client.post(
"/v1/memories",
json=make_record(text="contenuto del tutto diverso", supersedes_id=old_id, supersede_reason="fix"),
headers=auth_headers(),
)
assert r2.status_code == 200
data = r2.json()
assert data["supersede_warning"]["cross_score"] < 0.1
assert "lineage" in data["supersede_warning"]["message"]
def test_supersede_cross_alto_nessun_warning(client, monkeypatch):
monkeypatch.setattr("config.GUARDRAIL_SUPERSEDE_CHECK", True)
monkeypatch.setattr("rerank.enabled", lambda: True)
async def fake_rerank(query, docs):
return [3.0], "finto", 5 # sigmoid ≈ 0.95 ≥ 0.50
monkeypatch.setattr("rerank.rerank", fake_rerank)
r1 = client.post("/v1/memories", json=make_record(text="record originale"), headers=auth_headers())
old_id = r1.json()["memory_id"]
r2 = client.post(
"/v1/memories",
json=make_record(text="record originale corretto", supersedes_id=old_id, supersede_reason="correzione"),
headers=auth_headers(),
)
assert r2.status_code == 200
assert "supersede_warning" not in r2.json()
# ---------------------------------------------------------------------------
# C: score composito
# ---------------------------------------------------------------------------
def test_composite_score_in_risultati(client, monkeypatch):
client.fake_qdrant.query_score = 0.5 # il guardrail cosine non blocca il seeding
client.post("/v1/memories", json=make_record(text="record alpha", importance=1.0, confidence="high"), headers=auth_headers())
client.post("/v1/memories", json=make_record(text="record beta", importance=0.0, confidence="low"), headers=auth_headers())
async def fake_rerank(query, docs):
return [2.0, 2.0], "finto", 5 # rerank in parità → il composito decide
monkeypatch.setattr("rerank.enabled", lambda: True)
monkeypatch.setattr("rerank.rerank", fake_rerank)
resp = client.post("/v1/memories:search", json={"query": "record", "top_k": 2}, headers=auth_headers())
assert resp.status_code == 200
data = resp.json()
results = data["results"]
assert all("composite_score" in r for r in results)
assert results[0]["text"] == "record alpha"
assert results[0]["composite_score"] > results[1]["composite_score"]
# ---------------------------------------------------------------------------
# E: multi-query
# ---------------------------------------------------------------------------
def test_multi_query_pool_unito(client, monkeypatch):
client.fake_qdrant.query_score = 0.5 # il guardrail cosine non blocca il seeding
for text in ["alpha", "beta", "gamma"]:
client.post("/v1/memories", json=make_record(text=text), headers=auth_headers())
embed_calls: list[str] = []
async def fake_embed(text):
embed_calls.append(text)
return [0.0] * 1024
monkeypatch.setattr("state.embed", fake_embed)
monkeypatch.setattr("state.sparse_encode", lambda text: None)
async def fake_rerank(query, docs):
assert query == "alpha" # il rerank usa la query principale
return [0.9, 0.5, 0.1], "finto", 5
monkeypatch.setattr("rerank.enabled", lambda: True)
monkeypatch.setattr("rerank.rerank", fake_rerank)
resp = client.post(
"/v1/memories:search",
json={"query": "alpha", "queries": ["gamma", "alpha "], "top_k": 3},
headers=auth_headers(),
)
assert resp.status_code == 200
data = resp.json()
assert data["rerank"]["queries_used"] == 2 # "alpha " normalizzata e deduplicata
assert len(embed_calls) == 2
assert set(r["text"] for r in data["results"]) == {"alpha", "beta", "gamma"}
assert data["results"][0]["text"] == "alpha"
# ---------------------------------------------------------------------------
# Primitiva /v1/score
# ---------------------------------------------------------------------------
def test_score_endpoint_ok(client, monkeypatch):
async def fake_rerank(query, docs):
assert query == "q"
return [2.0, -3.0], "finto", 7
monkeypatch.setattr("rerank.enabled", lambda: True)
monkeypatch.setattr("rerank.rerank", fake_rerank)
resp = client.post("/v1/score", json={"query": "q", "documents": ["a", "b"]}, headers=auth_headers())
assert resp.status_code == 200
data = resp.json()
assert data["scores"][0] == pytest.approx(0.88, abs=0.01)
assert data["scores"][1] < 0.1
assert data["backend"] == "finto"
def test_score_endpoint_503_se_catena_giu(client, monkeypatch):
async def fail_rerank(query, docs):
return None
monkeypatch.setattr("rerank.enabled", lambda: True)
monkeypatch.setattr("rerank.rerank", fail_rerank)
resp = client.post("/v1/score", json={"query": "q", "documents": ["a"]}, headers=auth_headers())
assert resp.status_code == 503