feat(gateway): strategie rerank oltre la search (A-F)

- A: gate store con cross-encoder — guardrail.decide async, conferma/scarta
  quasi-duplicati (CROSS_DUP_CONFIRMED/WEAK/LOW_COSINE), suggerimento
  supersedes in WARN, degrada a cosine-only se il reranker è giù
- B: verifica supersede — cross-score (nuovo,vecchio) sotto soglia →
  supersede_warning non bloccante + audit
- C: score composito in search — rerank + importance (nuovo campo payload)
  + recency decay (180gg) + authority, pesi SCORE_W_* da env
- E: multi-query — SearchIn.queries (max 3), pool unito con dedup, rerank
  unico; endpoint POST /v1/score come primitiva cross-encoder (F-lite)
- extension search.ts: param queries + rerank_score/composite in output
- D: scripts/consolidate.py — dedup periodico a coppie via cross-encoder
  con report ntfy e --apply via gateway
- test: 69 pass (+11 strategie); guardrail_version similarity-v2
This commit is contained in:
enne2
2026-09-08 12:57:33 +02:00
parent fcd6b1670e
commit 128059dd4b
9 changed files with 671 additions and 18 deletions
+8 -1
View File
@@ -47,6 +47,12 @@ export function registerQmemSearch(pi: ExtensionAPI) {
description: "Include private records only for explicit sensitive-data lookup.", description: "Include private records only for explicit sensitive-data lookup.",
}), }),
), ),
queries: Type.Optional(
Type.Array(Type.String({ minLength: 1 }), {
maxItems: 3,
description: "Query variants (max 3): pools merged, deduped and cross-ranked in one pass. Improves recall on long-tail queries.",
}),
),
}), }),
async execute(toolCallId, params, signal, onUpdate, ctx) { async execute(toolCallId, params, signal, onUpdate, ctx) {
const cfg = loadConfig(); const cfg = loadConfig();
@@ -75,6 +81,7 @@ export function registerQmemSearch(pi: ExtensionAPI) {
parent_id: p.parent_id, parent_id: p.parent_id,
level: p.level, level: p.level,
topic: p.topic, topic: p.topic,
...(p.queries && p.queries.length > 0 ? { queries: p.queries } : {}),
}, },
signal, signal,
); );
@@ -102,7 +109,7 @@ export function registerQmemSearch(pi: ExtensionAPI) {
const top = r.topic ? ` (${r.topic})` : ""; const top = r.topic ? ` (${r.topic})` : "";
const parent = r.parent_id ? `, parent: ${r.parent_id}` : ""; const parent = r.parent_id ? `, parent: ${r.parent_id}` : "";
const links = r.links && r.links.length > 0 ? `, links: ${r.links.length}` : ""; const links = r.links && r.links.length > 0 ? `, links: ${r.links.length}` : "";
return `${i + 1}. [${r.kind}/${r.scope}${lvl}${top} score=${r.score}${r.score < 0.6 ? " ⚠️" : ""}${r.confidence ? ` conf=${r.confidence}` : ""}] ${r.text}\n (id: ${r.memory_id}${parent}${links}, agente: ${r.agent_id ?? "?"}, creato: ${r.created_at ?? "?"}${r.source ? `, fonte: ${r.source}` : ""}${r.supersedes_id ? `, supersede ${r.supersedes_id}` : ""}${r.superseded_by ? `, ⚠️ superseduto da ${r.superseded_by}` : ""})`; return `${i + 1}. [${r.kind}/${r.scope}${lvl}${top} score=${r.score}${r.score < 0.6 ? " ⚠️" : ""}${r.rerank_score != null ? ` rerank=${r.rerank_score}` : ""}${r.composite_score != null ? ` composite=${r.composite_score}` : ""}${r.confidence ? ` conf=${r.confidence}` : ""}] ${r.text}\n (id: ${r.memory_id}${parent}${links}, agente: ${r.agent_id ?? "?"}, creato: ${r.created_at ?? "?"}${r.importance != null && r.importance !== 0.5 ? `, importanza: ${r.importance}` : ""}${r.source ? `, fonte: ${r.source}` : ""}${r.supersedes_id ? `, supersede ${r.supersedes_id}` : ""}${r.superseded_by ? `, ⚠️ superseduto da ${r.superseded_by}` : ""})`;
}, },
); );
return { return {
+15 -2
View File
@@ -28,9 +28,22 @@ MAX_TEXT_LEN = int(os.environ.get("MAX_TEXT_LEN", "8000"))
GUARDRAIL_ENABLED = os.environ.get("GUARDRAIL_ENABLED", "true").lower() == "true" GUARDRAIL_ENABLED = os.environ.get("GUARDRAIL_ENABLED", "true").lower() == "true"
GUARDRAIL_BLOCK_THRESHOLD = float(os.environ.get("GUARDRAIL_BLOCK_THRESHOLD", "0.85")) 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_WARN_THRESHOLD = float(os.environ.get("GUARDRAIL_WARN_THRESHOLD", "0.70"))
GUARDRAIL_VERSION = "similarity-v1" 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.88"))
GUARDRAIL_RERANK_SUGGEST = float(os.environ.get("GUARDRAIL_RERANK_SUGGEST", "0.80"))
# 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() GIT_COMMIT = os.environ.get("GIT_COMMIT", "unknown").strip()
GATEWAY_VERSION = os.environ.get("GATEWAY_VERSION", "2.10.0").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_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")) VM_PUSH_INTERVAL = int(os.environ.get("VM_PUSH_INTERVAL", "30"))
+74 -6
View File
@@ -1,4 +1,12 @@
"""Guardrail anti-duplicati e similarità pre-scrittura.""" """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 from __future__ import annotations
import hashlib import hashlib
@@ -7,7 +15,15 @@ from typing import Any, Optional
from qdrant_client.http import models as qm from qdrant_client.http import models as qm
from config import GUARDRAIL_BLOCK_THRESHOLD, GUARDRAIL_WARN_THRESHOLD 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: def normalize_text(text: str) -> str:
@@ -35,7 +51,24 @@ def find_similar(qdrant: Any, collection: str, text: str, vector: list[float], t
] ]
def decide(qdrant: Any, collection: str, text: str, vector: list[float], topic: Optional[str] = None, parent_id: Optional[str] = None) -> dict: 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=[ exact_filter = qm.Filter(must=[
qm.FieldCondition(key="text_hash", match=qm.MatchValue(value=text_hash(text))), qm.FieldCondition(key="text_hash", match=qm.MatchValue(value=text_hash(text))),
qm.IsEmptyCondition(is_empty=qm.PayloadField(key="superseded_by")), qm.IsEmptyCondition(is_empty=qm.PayloadField(key="superseded_by")),
@@ -47,13 +80,48 @@ def decide(qdrant: Any, collection: str, text: str, vector: list[float], topic:
matches = find_similar(qdrant, collection, text, vector, top_k=3) matches = find_similar(qdrant, collection, text, vector, top_k=3)
if not matches: if not matches:
return {"decision": "ALLOW", "reason": "NO_CANDIDATE", "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"] 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 top1 >= GUARDRAIL_BLOCK_THRESHOLD:
if (topic or parent_id) and any(m.get("memory_id") != parent_id for m in matches): if hierarchical:
return {"decision": "WARN", "reason": "HIERARCHICAL_SUBTOPIC", "matches": matches} 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} return {"decision": "BLOCK", "reason": "KNOWN_SOLUTION", "matches": matches}
if top1 >= GUARDRAIL_WARN_THRESHOLD: if top1 >= GUARDRAIL_WARN_THRESHOLD:
return {"decision": "WARN", "reason": "MODERATE_SIMILARITY", "matches": matches} 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} return {"decision": "ALLOW", "reason": "NEW_SOLUTION", "matches": matches}
@@ -64,4 +132,4 @@ def parse_ts(value: Optional[str]) -> Optional[float]:
try: try:
return datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp() return datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp()
except ValueError: except ValueError:
return None return None
+20
View File
@@ -30,6 +30,7 @@ class MemoryIn(BaseModel):
level: Optional[Literal["L1_ROOT", "L2_SUBTOPIC", "L3_DETAIL"]] = Field(default=None, description="Livello gerarchico") 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") topic: Optional[str] = Field(default=None, max_length=128, description="Topic gerarchico")
links: Optional[list[MemoryLink]] = Field(default=None, description="Collegamenti semantici e relazionali") 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") private: bool = Field(default=False, description="Riservato: escluso dalle ricerche standard, visibile solo con include_private o topic esplicito")
@field_validator("expires_at") @field_validator("expires_at")
@@ -44,6 +45,13 @@ class MemoryIn(BaseModel):
return v 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): class SearchIn(BaseModel):
query: str = Field(min_length=1, max_length=512) query: str = Field(min_length=1, max_length=512)
kind: Optional[Literal["decision", "fact", "episode", "preference"]] = None kind: Optional[Literal["decision", "fact", "episode", "preference"]] = None
@@ -58,3 +66,15 @@ class SearchIn(BaseModel):
topic: Optional[str] = None topic: Optional[str] = None
include_private: bool = Field(default=False, description="Includi i record privati (solo ricerche esplicite)") 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)") 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")
+82 -8
View File
@@ -5,6 +5,7 @@ import hashlib
import time import time
import uuid import uuid
from collections import Counter from collections import Counter
from datetime import datetime
from typing import Any, Optional from typing import Any, Optional
from fastapi import APIRouter, Depends, Header, HTTPException, Request from fastapi import APIRouter, Depends, Header, HTTPException, Request
@@ -26,12 +27,20 @@ from config import (
GATEWAY_VERSION, GATEWAY_VERSION,
GUARDRAIL_BLOCK_THRESHOLD, GUARDRAIL_BLOCK_THRESHOLD,
GUARDRAIL_ENABLED, GUARDRAIL_ENABLED,
GUARDRAIL_RERANK_BLOCK,
GUARDRAIL_SUPERSEDE_CHECK,
GUARDRAIL_SUPERSEDE_MIN,
GUARDRAIL_VERSION, GUARDRAIL_VERSION,
GUARDRAIL_WARN_THRESHOLD, GUARDRAIL_WARN_THRESHOLD,
MAX_TEXT_LEN, MAX_TEXT_LEN,
RERANK_CANDIDATES, RERANK_CANDIDATES,
SCORE_DECAY_HALF_LIFE_DAYS,
SCORE_W_AUTHORITY,
SCORE_W_IMPORTANCE,
SCORE_W_RECENCY,
SCORE_W_RELEVANCE,
) )
from models import MemoryIn, SearchIn from models import MemoryIn, ScoreIn, SearchIn
router = APIRouter() router = APIRouter()
@@ -54,6 +63,7 @@ async def add_memory(
memory_id = str(uuid.uuid4()) memory_id = str(uuid.uuid4())
superseded_id: Optional[str] = None superseded_id: Optional[str] = None
supersede_warning: Optional[dict] = None
if body.supersedes_id: if body.supersedes_id:
old = state.qdrant.retrieve(collection_name=COLLECTION, ids=[body.supersedes_id], with_payload=True) old = state.qdrant.retrieve(collection_name=COLLECTION, ids=[body.supersedes_id], with_payload=True)
if not old: if not old:
@@ -61,12 +71,23 @@ async def add_memory(
if old[0].payload.get("superseded_by"): if old[0].payload.get("superseded_by"):
raise HTTPException(status_code=409, detail="La memoria è già stata superseduta: correggi la versione attiva") raise HTTPException(status_code=409, detail="La memoria è già stata superseduta: correggi la versione attiva")
superseded_id = body.supersedes_id 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) vector = await state.embed(body.text)
sparse = state.sparse_encode(body.text) sparse = state.sparse_encode(body.text)
similarity_guardrail: Optional[dict] = None similarity_guardrail: Optional[dict] = None
if config.GUARDRAIL_ENABLED and not body.supersedes_id: if config.GUARDRAIL_ENABLED and not body.supersedes_id:
similarity_guardrail = guardrail.decide(state.qdrant, COLLECTION, body.text, vector, topic=body.topic, parent_id=body.parent_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": 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"]]) 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( raise HTTPException(
@@ -95,6 +116,7 @@ async def add_memory(
"parent_id": body.parent_id, "parent_id": body.parent_id,
"level": body.level, "level": body.level,
"topic": body.topic, "topic": body.topic,
"importance": body.importance,
"links": [link.model_dump() for link in body.links] if body.links else None, "links": [link.model_dump() for link in body.links] if body.links else None,
"embedding_model": EMBED_MODEL, "embedding_model": EMBED_MODEL,
"text_hash": guardrail.text_hash(body.text), "text_hash": guardrail.text_hash(body.text),
@@ -106,6 +128,8 @@ async def add_memory(
"reason": similarity_guardrail["reason"], "reason": similarity_guardrail["reason"],
"matches": similarity_guardrail["matches"], "matches": similarity_guardrail["matches"],
} }
if similarity_guardrail.get("suggestion"):
payload["guardrail"]["suggestion"] = similarity_guardrail["suggestion"]
point_vector: dict[str, Any] = {"": vector} point_vector: dict[str, Any] = {"": vector}
if sparse is not None: if sparse is not None:
point_vector["bm25"] = sparse point_vector["bm25"] = sparse
@@ -127,29 +151,60 @@ async def add_memory(
audit(key, "create", memory_id=memory_id, kind=body.kind, agent_id=payload["agent_id"], guardrail=payload.get("guardrail", {}).get("decision", "ALLOW")) 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} 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: if idem_key:
state.idempotency[idem_key] = {"hash": state.payload_hash(body), "response": response, "ts": time.time()} state.idempotency[idem_key] = {"hash": state.payload_hash(body), "response": response, "ts": time.time()}
return response 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") @router.post("/v1/memories:search")
async def search_memories(body: SearchIn, key: str = Depends(require_auth)) -> dict: async def search_memories(body: SearchIn, key: str = Depends(require_auth)) -> dict:
use_rerank = rerank.enabled() and body.rerank is not False 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 # 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 limit = max(body.top_k, RERANK_CANDIDATES) if use_rerank else body.top_k
vector = await state.embed(body.query)
sparse = state.sparse_encode(body.query) if body.hybrid else None
hits = store.search(state.qdrant, COLLECTION, body, vector, sparse, limit=limit)
results = store.format_results(hits)
rerank_info: dict = {"enabled": use_rerank, "used": False} # 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: if use_rerank and len(results) >= 2:
rr = await rerank.rerank(body.query, [r["text"] or "" for r in results]) rr = await rerank.rerank(body.query, [r["text"] or "" for r in results])
if rr: if rr:
scores, backend, took_ms = rr scores, backend, took_ms = rr
now = time.time()
for r, s in zip(results, scores): for r, s in zip(results, scores):
r["rerank_score"] = round(rerank.normalize_score(s), 4) r["rerank_score"] = round(rerank.normalize_score(s), 4)
results.sort(key=lambda r: r["rerank_score"], reverse=True) 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)) rerank_info.update(used=True, backend=backend, took_ms=took_ms, candidates=len(results))
else: else:
rerank_info["reason"] = "tutti i nodi rerank non raggiungibili (ordine di fusione preservato)" rerank_info["reason"] = "tutti i nodi rerank non raggiungibili (ordine di fusione preservato)"
@@ -165,11 +220,30 @@ async def search_memories(body: SearchIn, key: str = Depends(require_auth)) -> d
min_score=body.min_score, min_score=body.min_score,
hits=len(results), hits=len(results),
rerank_backend=rerank_info.get("backend"), rerank_backend=rerank_info.get("backend"),
queries_used=len(queries),
) )
metrics.record_search(len(results)) metrics.record_search(len(results))
return {"results": results, "min_score": body.min_score, "total_hits": len(results), "rerank": rerank_info} 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}") @router.get("/v1/memories/{memory_id}")
async def get_memory(memory_id: str, key: str = Depends(require_auth)) -> dict: async def get_memory(memory_id: str, key: str = Depends(require_auth)) -> dict:
point = state.qdrant.retrieve(collection_name=COLLECTION, ids=[memory_id], with_payload=True) point = state.qdrant.retrieve(collection_name=COLLECTION, ids=[memory_id], with_payload=True)
+1
View File
@@ -64,6 +64,7 @@ def format_results(hits: list[Any]) -> list[dict]:
"scope": h.payload.get("scope"), "scope": h.payload.get("scope"),
"project_id": h.payload.get("project_id"), "project_id": h.payload.get("project_id"),
"confidence": h.payload.get("confidence"), "confidence": h.payload.get("confidence"),
"importance": h.payload.get("importance", 0.5),
"created_at": h.payload.get("created_at"), "created_at": h.payload.get("created_at"),
"source": h.payload.get("source"), "source": h.payload.get("source"),
"supersedes_id": h.payload.get("supersedes_id"), "supersedes_id": h.payload.get("supersedes_id"),
+1 -1
View File
@@ -292,7 +292,7 @@ def test_version_endpoint_pubblico(client):
assert "git_commit" in data assert "git_commit" in data
assert "version" in data assert "version" in data
assert "guardrail_version" in data assert "guardrail_version" in data
assert data["guardrail_version"] == "similarity-v1" assert data["guardrail_version"] == "similarity-v2"
def test_status_espone_git_commit(client): def test_status_espone_git_commit(client):
+252
View File
@@ -0,0 +1,252 @@
"""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.5], "finto", 5 # sigmoid ≈ 0.818: ≥ 0.80, < 0.88
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
+218
View File
@@ -0,0 +1,218 @@
#!/usr/bin/env python3
"""Consolidamento assistito da cross-encoder (strategia D).
Trova i veri duplicati nella collection 'memories': candidatos per cosine
(bi-encoder) cross-score a coppie col reranker (giudice "è lo stesso fatto?")
cluster di duplicati confermati report (e, con --apply, rimozione dei
duplicati perdenti via API gateway, con audit).
Uso (su brain):
python3 consolidate.py # report su stdout (+ ntfy se configurato)
python3 consolidate.py --apply # applica le rimozioni suggerite
python3 consolidate.py --limit 300 # limita il numero di record scansionati
Env (da /opt/memory/.env se presente): QDRANT_URL, QDRANT_API_KEY, API_KEYS,
RERANK_CHAIN, NTFY_CONSOLIDAMENTO (opzionale: URL completo del topic ntfy).
"""
from __future__ import annotations
import argparse
import json
import math
import os
import sys
import time
from datetime import datetime
import httpx
DEFAULT_ENV_FILE = "/opt/memory/.env"
def load_env(path: str) -> dict:
env = {}
if os.path.exists(path):
for line in open(path):
line = line.strip()
if line and not line.startswith("#") and "=" in line:
k, v = line.split("=", 1)
env[k] = v
return env
def sigmoid(x: float) -> float:
if x >= 0:
z = math.exp(-x)
return 1.0 / (1.0 + z)
z = math.exp(x)
return z / (1.0 + z)
def parse_chain(raw: str) -> list[dict]:
try:
entries = json.loads(raw) if raw else []
except json.JSONDecodeError:
return []
return [e for e in entries if isinstance(e, dict) and e.get("url")]
def cross_score(http, chain: list[dict], query: str, docs: list[str], timeout_default: float) -> tuple[list[float], str] | None:
payload = {"model": "bge-reranker-v2-m3", "query": query, "documents": docs, "top_n": len(docs)}
for node in chain:
headers = {"Content-Type": "application/json"}
if node.get("key"):
headers["Authorization"] = f"Bearer {node['key']}"
try:
r = http.post(
f"{node['url'].rstrip('/')}/v1/rerank",
json=payload,
headers=headers,
timeout=node.get("timeout_ms", 10000) / 1000.0,
)
r.raise_for_status()
scores = [0.0] * len(docs)
for item in r.json().get("results", []):
idx = int(item["index"])
if 0 <= idx < len(docs):
scores[idx] = sigmoid(float(item.get("relevance_score", 0.0)))
return scores, node.get("name", node["url"])
except Exception:
continue
return None
def recency_of(created_at: str, half_life_days: float = 180.0) -> float:
try:
age = (time.time() - datetime.fromisoformat(str(created_at).replace("Z", "+00:00")).timestamp()) / 86400.0
except Exception:
return 0.5
return pow(0.5, max(0.0, age) / half_life_days)
import time # noqa: E402 (dopo i docstring per leggibilità dell'ordine di import)
def priority(rec: dict, recency: float) -> float:
"""Chi resta nel cluster: confidence + importance + recency."""
conf = {"high": 1.0, "medium": 0.7, "low": 0.4}.get(rec.get("confidence"), 0.7)
return conf * 0.5 + float(rec.get("importance", 0.5) or 0.5) * 0.3 + recency * 0.2
def main() -> int:
ap = argparse.ArgumentParser(description="Consolidamento duplicati via cross-encoder")
ap.add_argument("--env-file", default=DEFAULT_ENV_FILE)
ap.add_argument("--limit", type=int, default=0, help="max record da scansionare (0 = tutti)")
ap.add_argument("--cosine", type=float, default=0.70, help="soglia cosine per i candidati")
ap.add_argument("--cross", type=float, default=0.88, help="soglia cross-encoder per duplicato confermato")
ap.add_argument("--apply", action="store_true", help="rimuove i duplicati perdenti via API gateway")
ap.add_argument("--gateway-url", default=os.environ.get("GATEWAY_URL", "http://127.0.0.1:8082"))
ap.add_argument("--ntfy", default="", help="URL topic ntfy per il report (es. http://127.0.0.1:8091/qmem-consolidamento)")
args = ap.parse_args()
env = load_env(args.env_file)
env.update({k: v for k, v in os.environ.items() if k in ("QDRANT_URL", "QDRANT_API_KEY", "RERANK_CHAIN", "GATEWAY_URL")})
qdrant_url = env.get("QDRANT_URL", "http://127.0.0.1:6333").rstrip("/")
api_key = env.get("QDRANT_API_KEY", "")
chain = parse_chain(env.get("RERANK_CHAIN", ""))
if not chain:
print("RERANK_CHAIN vuota: niente cross-scoring, esco", file=sys.stderr)
return 2
import httpx
headers = {"api-key": api_key} if api_key else {}
with httpx.Client(timeout=60) as http:
# 1) scroll record attivi (id, testo, metadata, vettore denso)
records: dict[str, dict] = {}
offset = None
while True:
body: dict = {
"filter": {"must": [{"key": "superseded_by", "match": None}]},
"limit": 256,
"with_payload": True,
"with_vector": True,
}
if offset:
body["offset"] = offset
r = http.post(f"{qdrant_url}/collections/memories/points/scroll", json=body, headers=headers)
r.raise_for_status()
data = r.json()
for p in data.get("points", []):
vec = (p.get("vector") or {}).get("") if isinstance(p.get("vector"), dict) else p.get("vector")
if not vec:
continue
records[p["id"]] = {
"text": (p.get("payload") or {}).get("text", ""),
"confidence": (p.get("payload") or {}).get("confidence", "medium"),
"importance": (p.get("payload") or {}).get("importance", 0.5),
"created_at": (p.get("payload") or {}).get("created_at", ""),
"vector": vec,
}
offset = data.get("next_page_offset")
if not offset:
break
if args.limit:
records = dict(list(records.items())[: args.limit])
print(f"scansionati {len(records)} record attivi")
# 2) candidati per cosine (il vettore del record stesso come query)
pairs: set[tuple[str, str]] = set()
for rid, rec in records.items():
r = http.post(
f"{qdrant_url}/collections/memories/points/query",
json={"query": rec["vector"], "limit": 4, "with_payload": False},
headers=headers,
)
r.raise_for_status()
for h in r.json().get("points", []):
oid = h["id"]
if oid == rid or oid not in records or h["score"] < args.cosine:
continue
pairs.add((min(rid, oid), max(rid, oid)))
print(f"coppie candidate (cosine ≥ {args.cosine}): {len(pairs)}")
# 3) cross-score a coppie
confirmed: list[dict] = []
for a, b in sorted(pairs):
rr = cross_score(http, chain, records[a]["text"][:800], [records[b]["text"][:800]], 10.0)
if rr is None:
print("catena rerank irraggiungibile: interrompo il cross-scoring", file=sys.stderr)
return 3
cross = rr[0][0]
if cross >= args.cross:
keep, drop = (a, b) if priority(records[a], recency_of(records[a]["created_at"])) >= priority(records[b], recency_of(records[b]["created_at"])) else (b, a)
confirmed.append({"keep": keep, "drop": drop, "cross": round(cross, 4)})
# 4) report
print(f"duplicati confermati (cross ≥ {args.cross}): {len(confirmed)}")
for c in confirmed:
keep_txt = records[c["keep"]]["text"][:70].replace("\n", " ")
drop_txt = records[c["drop"]]["text"][:60].replace("\n", " ")
print(f" KEEP {c['keep']} DROP {c['drop']} cross={c['cross']} | drop: {drop_txt}")
if args.apply and confirmed:
gw_headers = {"Content-Type": "application/json", "X-API-Key": env.get("API_KEYS", "").split(",")[0]}
removed = 0
for c in confirmed:
try:
r = http.delete(f"{args.gateway_url.rstrip('/')}/v1/memories/{c['drop']}", headers=gw_headers)
if r.status_code == 200:
removed += 1
else:
print(f" delete {c['drop']}: HTTP {r.status_code}", file=sys.stderr)
except Exception as exc:
print(f" delete {c['drop']}: {exc}", file=sys.stderr)
print(f"rimossi {removed}/{len(confirmed)} duplicati")
if args.ntfy and confirmed:
lines = [f"qmem consolidamento: {len(confirmed)} duplicati confermati"]
lines += [f"{c['cross']}{records[c['drop']]['text'][:60]}" for c in confirmed[:5]]
try:
http.post(args.ntfy, data="\n".join(lines).encode(), headers={"Title": "qmem consolidamento"})
except Exception as exc:
print(f"ntfy: {exc}", file=sys.stderr)
return 0
if __name__ == "__main__":
sys.exit(main())