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:
+68
-1
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user