feat: discovery endpoint GET /v1/meta/overview + tool qmem_meta

- gateway v2.3.0: /v1/meta/overview (auth) con scope×kind, progetti, agenti, superseduti; scroll aggregato su soli campi metadata + cache TTL 60s invalidata su POST/DELETE/cleanup; audit action 'meta'
- estensione v1.3.0: tool qmem_meta (nessun parametro) che guida la ricerca settorializzata
- README/playbook (sez. 11.4)/AGENTS.md aggiornati
This commit is contained in:
enne2
2026-08-13 13:06:10 +02:00
parent f6d9c5872d
commit 4b1f13c118
5 changed files with 124 additions and 2 deletions
+68 -1
View File
@@ -12,6 +12,7 @@ Endpoints:
POST /v1/memories:search → ricerca semantica con filtri (include_superseded per la lineage)
GET /v1/memories/{id} → recupera per UUID
DELETE /v1/memories/{id} → elimina per UUID
GET /v1/meta/overview → discovery: scope×kind, progetti, agenti (cache 60s)
GET /v1/status → health + statistiche
"""
@@ -21,6 +22,7 @@ import logging
import os
import time
import uuid
from collections import Counter
from datetime import datetime, timezone
from typing import Any, Literal, Optional
@@ -48,12 +50,20 @@ MAX_TEXT_LEN = int(os.environ.get("MAX_TEXT_LEN", "8000"))
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("memory-gateway")
app = FastAPI(title="Memory Gateway", version="2.2.0")
app = FastAPI(title="Memory Gateway", version="2.3.0")
qdrant = QdrantClient(url=QDRANT_URL, api_key=QDRANT_API_KEY)
# Rate limit in-memory: {key: [timestamps]}
_ratelimit: dict[str, list[float]] = {}
# Cache overview metadati (TTL 60s, invalidata su scrittura)
_META_TTL_SECONDS = 60
_meta_cache: dict[str, Any] = {}
def _invalidate_meta() -> None:
_meta_cache.clear()
# ---------------------------------------------------------------------------
# Modelli
@@ -192,6 +202,7 @@ async def add_memory(body: MemoryIn, key: str = Depends(require_auth)) -> dict:
collection_name=COLLECTION,
points=[qm.PointStruct(id=memory_id, vector=vector, payload=payload)],
)
_invalidate_meta()
if superseded_id:
qdrant.set_payload(
@@ -272,10 +283,65 @@ async def delete_memory(memory_id: str, key: str = Depends(require_auth)) -> dic
if not point:
raise HTTPException(status_code=404, detail="Memoria non trovata")
qdrant.delete(collection_name=COLLECTION, points_selector=[memory_id])
_invalidate_meta()
_audit(key, "delete", memory_id=memory_id)
return {"deleted": memory_id}
@app.get("/v1/meta/overview")
async def meta_overview(key: str = Depends(require_auth)) -> dict:
"""Panoramica della memoria: scope×kind con conteggi, progetti, agenti, superseduti.
Usata dalla discovery per la ricerca settorializzata. Cache TTL 60s invalidata su write."""
now = time.time()
cached = _meta_cache.get("overview")
if cached and now - cached["ts"] < _META_TTL_SECONDS:
_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 = qdrant.scroll(
collection_name=COLLECTION,
limit=1000,
with_payload=["scope", "kind", "project_id", "agent_id", "superseded_by"],
with_vector=False,
offset=offset,
)
for p in points:
pl = p.payload
total += 1
s = pl.get("scope", "agent")
k = pl.get("kind", "fact")
scope_kinds.setdefault(s, Counter())[k] += 1
if pl.get("project_id"):
projects[pl["project_id"]] += 1
agents[pl.get("agent_id", "shared")] += 1
if pl.get("superseded_by"):
superseded += 1
if not next_offset:
break
offset = next_offset
data = {
"scopes": [
{"scope": s, "count": sum(c.values()), "kinds": [{"kind": k, "count": v} for k, v in sorted(c.items())]}
for s, c in sorted(scope_kinds.items())
],
"projects": [{"project_id": pid, "count": c} for pid, c in projects.most_common()],
"agents": [{"agent_id": aid, "count": c} for aid, c in agents.most_common()],
"superseded": superseded,
"total": total,
}
_meta_cache["overview"] = {"ts": now, "data": data}
_audit(key, "meta", cached=False, total=total)
return {**data, "cached": False}
@app.get("/v1/status")
async def status() -> dict:
info = qdrant.get_collection(COLLECTION)
@@ -313,6 +379,7 @@ async def _cleanup_loop() -> None:
ids = [p.id for p 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 e: # noqa: BLE001
log.warning("cleanup error: %s", e)