Files
pi-qmem/gateway/routes.py
T
enne2 86ac1996fb feat(private): memorie riservate escluse dalle ricerche standard
- gateway: campo private (bool) in MemoryIn; filtro must_not private=true
  di default in search_filter; include_private in SearchIn per ricerche
  esplicite; campo private esposto in format_results
- estensione: parametro private in qmem_store, include_private in qmem_search
  (con descrizioni e avvertenze sui prompt dei modelli)
- testato su brain: record private invisibile alla ricerca standard,
  visibile solo con include_private=true; topic esplicito da solo non sblocca
- deploy: /opt/memory/gateway ricostruito (container memory-gateway)
2026-08-25 20:14:28 +02:00

227 lines
9.9 KiB
Python

"""Endpoint HTTP del Memory Gateway."""
from __future__ import annotations
import hashlib
import time
import uuid
from collections import Counter
from typing import Any, Optional
from fastapi import APIRouter, Depends, Header, HTTPException, Request
from qdrant_client.http import models as qm
import config
import guardrail
import metrics
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_VERSION,
GUARDRAIL_WARN_THRESHOLD,
MAX_TEXT_LEN,
)
from models import MemoryIn, 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
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
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 = 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,
"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"],
}
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 idem_key:
state.idempotency[idem_key] = {"hash": state.payload_hash(body), "response": response, "ts": time.time()}
return response
@router.post("/v1/memories:search")
async def search_memories(body: SearchIn, key: str = Depends(require_auth)) -> dict:
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)
results = store.format_results(hits)
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))
metrics.record_search(len(results))
return {"results": results, "min_score": body.min_score, "total_hits": len(results)}
@router.get("/v1/memories/{memory_id}")
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)
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}
@router.get("/v1/metrics")
async def metrics_endpoint(key: str = Depends(require_auth)) -> dict:
return metrics.snapshot(state.qdrant, COLLECTION)