"""Guardrail anti-duplicati e similarità pre-scrittura.""" from __future__ import annotations import hashlib import unicodedata from typing import Any, Optional from qdrant_client.http import models as qm from config import GUARDRAIL_BLOCK_THRESHOLD, GUARDRAIL_WARN_THRESHOLD 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 = 3) -> 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 ] 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": []} top1 = matches[0]["score"] if top1 >= GUARDRAIL_BLOCK_THRESHOLD: if (topic or parent_id) and any(m.get("memory_id") != parent_id for m in matches): return {"decision": "WARN", "reason": "HIERARCHICAL_SUBTOPIC", "matches": matches} return {"decision": "BLOCK", "reason": "KNOWN_SOLUTION", "matches": matches} if top1 >= GUARDRAIL_WARN_THRESHOLD: return {"decision": "WARN", "reason": "MODERATE_SIMILARITY", "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