feat(guardrail): similarità pre-scrittura su POST /v1/memories
Guardrail deterministico FUORI dall'LLM (stessa architettura di egeos-copilot): - Strato 1: text_hash SHA-256 normalizzato -> BLOCK 409 (EXACT_DUPLICATE) - Strato 2: similarità semantica top-3 BGE-M3 cosine -> BLOCK/WARN/ALLOW (soglie configurabili: GUARDRAIL_BLOCK_THRESHOLD 0.85, WARN 0.70) - Supersede esplicito bypassa il guardrail (correzione intenzionale) - text_hash e flag guardrail nel payload; audit create_blocked - Indice payload su text_hash - Test: 6 nuovi (duplicato esatto, similarità alta/moderata, nessun candidato, supersede bypass, disabilitato, text_hash) — 28/28 passano
This commit is contained in:
@@ -12,6 +12,8 @@ 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))
|
||||
|
||||
@@ -33,6 +35,7 @@ class FakeQdrant:
|
||||
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:
|
||||
@@ -94,7 +97,7 @@ class FakeQdrant:
|
||||
ok = False
|
||||
if not ok:
|
||||
continue
|
||||
results.append(type("H", (), {"id": p.id, "score": 0.9, "payload": pl})())
|
||||
results.append(type("H", (), {"id": p.id, "score": self.query_score, "payload": pl})())
|
||||
return type("R", (), {"points": results[:limit]})()
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
"""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 main as gateway
|
||||
|
||||
monkeypatch.setattr(gateway, "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 main as gateway
|
||||
|
||||
monkeypatch.setattr(gateway, "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
|
||||
Reference in New Issue
Block a user