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:
@@ -22,6 +22,7 @@ Oppure copia `extensions/index.ts` in `~/.pi/agent/extensions/pi-qmem/`.
|
|||||||
| `qmem_store` | Salva un record di memoria (text, kind, agent_id, scope, project_id, source, expires_at, supersedes_id, supersede_reason) |
|
| `qmem_store` | Salva un record di memoria (text, kind, agent_id, scope, project_id, source, expires_at, supersedes_id, supersede_reason) |
|
||||||
| `qmem_search` | Ricerca semantica su tutta la conoscenza condivisa (query, kind, project_id, scope, top_k, include_superseded, min_score) |
|
| `qmem_search` | Ricerca semantica su tutta la conoscenza condivisa (query, kind, project_id, scope, top_k, include_superseded, min_score) |
|
||||||
| `qmem_correct` | Corregge una memoria falsa: crea un nuovo record che **supersede** il vecchio (che resta in archivio marcato superseded) |
|
| `qmem_correct` | Corregge una memoria falsa: crea un nuovo record che **supersede** il vecchio (che resta in archivio marcato superseded) |
|
||||||
|
| `qmem_meta` | Discovery: panoramica di scope×kind, progetti, agenti e superseduti (per scegliere i filtri di ricerca) |
|
||||||
|
|
||||||
## Comando
|
## Comando
|
||||||
|
|
||||||
|
|||||||
@@ -340,6 +340,14 @@ pi install git:git.enne2.net/enne2/<repo>
|
|||||||
- `qmem_store` accetta anche `supersedes_id` / `supersede_reason` (supersede esplicito)
|
- `qmem_store` accetta anche `supersedes_id` / `supersede_reason` (supersede esplicito)
|
||||||
- `qmem_search` accetta `include_superseded`; i risultati mostrano `supersedes_id`/`superseded_by`/`supersede_reason`
|
- `qmem_search` accetta `include_superseded`; i risultati mostrano `supersedes_id`/`superseded_by`/`supersede_reason`
|
||||||
|
|
||||||
|
### 11.4 Discovery (meta overview)
|
||||||
|
|
||||||
|
- `GET /v1/meta/overview` (auth) → `{scopes:[{scope,count,kinds:[{kind,count}]}], projects:[{project_id,count}], agents:[...], superseded, total}`
|
||||||
|
- Implementazione: scroll con `with_payload` limitato ai soli campi metadata + aggregazione client (Counter) — OK fino a ~10k record, oltre passare a count-per-valore su indice keyword
|
||||||
|
- **Cache TTL 60s** nel gateway, invalidata a ogni POST/DELETE (e dal cleanup orario): costo marginale ≈ 0 per l'agente
|
||||||
|
- Tool estensione: `qmem_meta` (nessun parametro) → guida la ricerca settorializzata (`scope`/`kind`/`project_id`)
|
||||||
|
- Note: scope/kind sono enum chiusi (la discovery serve per conteggi e set aperti project_id/agent_id); espone i nomi reali di progetti/agenti (accesso condiviso già scelto)
|
||||||
|
|
||||||
### 11.3 Verifica rapida
|
### 11.3 Verifica rapida
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -393,6 +393,52 @@ export default function qmemExtension(pi: ExtensionAPI) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// TOOL: qmem_meta — discovery di scope, kind, progetti, agenti
|
||||||
|
// =========================================================================
|
||||||
|
pi.registerTool({
|
||||||
|
name: "qmem_meta",
|
||||||
|
label: "Qmem memory overview",
|
||||||
|
description:
|
||||||
|
"Restituisce la panoramica della memoria condivisa: scope con i relativi kind e conteggi, " +
|
||||||
|
"progetti, agenti e record superseduti. Usalo per decidere DOVE cercare (filtri " +
|
||||||
|
"scope/kind/project_id) prima di qmem_search su un dominio specifico, o per orientarti " +
|
||||||
|
"sui contenuti disponibili. Nessun parametro richiesto.",
|
||||||
|
parameters: Type.Object({}),
|
||||||
|
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
||||||
|
const cfg = loadConfig();
|
||||||
|
if (!cfg.apiKey) {
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: "Config mancante: esegui /qmem:config per impostare url e apiKey." }],
|
||||||
|
details: { error: "missing_config" },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
onUpdate?.({ content: [{ type: "text", text: "qmem: lettura overview..." }] });
|
||||||
|
const { ok, status, data } = await gatewayRequest(cfg, "GET", "/v1/meta/overview", undefined, signal);
|
||||||
|
if (!ok) {
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: `Errore ${status}: ${JSON.stringify(data)}` }],
|
||||||
|
details: { error: "gateway_error", status },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const lines: string[] = [];
|
||||||
|
lines.push(`Memoria condivisa: ${data.total} record (${data.superseded} superseduti${data.cached ? ", da cache" : ""})`);
|
||||||
|
lines.push("Scope:");
|
||||||
|
for (const s of data.scopes ?? []) {
|
||||||
|
const kinds = (s.kinds ?? []).map((k: any) => `${k.kind}=${k.count}`).join(", ");
|
||||||
|
lines.push(` ${s.scope} (${s.count}): ${kinds}`);
|
||||||
|
}
|
||||||
|
lines.push("Progetti:");
|
||||||
|
lines.push(` ${(data.projects ?? []).map((p: any) => `${p.project_id}(${p.count})`).join(", ") || "nessuno"}`);
|
||||||
|
lines.push("Agenti:");
|
||||||
|
lines.push(` ${(data.agents ?? []).map((a: any) => `${a.agent_id}(${a.count})`).join(", ") || "nessuno"}`);
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: lines.join("\n") }],
|
||||||
|
details: { total: data.total, superseded: data.superseded },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// COMANDO: /qmem:config — menu interattivo + modalità CLI rapida
|
// COMANDO: /qmem:config — menu interattivo + modalità CLI rapida
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|||||||
+68
-1
@@ -12,6 +12,7 @@ Endpoints:
|
|||||||
POST /v1/memories:search → ricerca semantica con filtri (include_superseded per la lineage)
|
POST /v1/memories:search → ricerca semantica con filtri (include_superseded per la lineage)
|
||||||
GET /v1/memories/{id} → recupera per UUID
|
GET /v1/memories/{id} → recupera per UUID
|
||||||
DELETE /v1/memories/{id} → elimina 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
|
GET /v1/status → health + statistiche
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -21,6 +22,7 @@ import logging
|
|||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
|
from collections import Counter
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any, Literal, Optional
|
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")
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||||
log = logging.getLogger("memory-gateway")
|
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)
|
qdrant = QdrantClient(url=QDRANT_URL, api_key=QDRANT_API_KEY)
|
||||||
|
|
||||||
# Rate limit in-memory: {key: [timestamps]}
|
# Rate limit in-memory: {key: [timestamps]}
|
||||||
_ratelimit: dict[str, list[float]] = {}
|
_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
|
# Modelli
|
||||||
@@ -192,6 +202,7 @@ async def add_memory(body: MemoryIn, key: str = Depends(require_auth)) -> dict:
|
|||||||
collection_name=COLLECTION,
|
collection_name=COLLECTION,
|
||||||
points=[qm.PointStruct(id=memory_id, vector=vector, payload=payload)],
|
points=[qm.PointStruct(id=memory_id, vector=vector, payload=payload)],
|
||||||
)
|
)
|
||||||
|
_invalidate_meta()
|
||||||
|
|
||||||
if superseded_id:
|
if superseded_id:
|
||||||
qdrant.set_payload(
|
qdrant.set_payload(
|
||||||
@@ -272,10 +283,65 @@ async def delete_memory(memory_id: str, key: str = Depends(require_auth)) -> dic
|
|||||||
if not point:
|
if not point:
|
||||||
raise HTTPException(status_code=404, detail="Memoria non trovata")
|
raise HTTPException(status_code=404, detail="Memoria non trovata")
|
||||||
qdrant.delete(collection_name=COLLECTION, points_selector=[memory_id])
|
qdrant.delete(collection_name=COLLECTION, points_selector=[memory_id])
|
||||||
|
_invalidate_meta()
|
||||||
_audit(key, "delete", memory_id=memory_id)
|
_audit(key, "delete", memory_id=memory_id)
|
||||||
return {"deleted": 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")
|
@app.get("/v1/status")
|
||||||
async def status() -> dict:
|
async def status() -> dict:
|
||||||
info = qdrant.get_collection(COLLECTION)
|
info = qdrant.get_collection(COLLECTION)
|
||||||
@@ -313,6 +379,7 @@ async def _cleanup_loop() -> None:
|
|||||||
ids = [p.id for p in scroll[0]]
|
ids = [p.id for p in scroll[0]]
|
||||||
if ids:
|
if ids:
|
||||||
qdrant.delete(collection_name=COLLECTION, points_selector=ids)
|
qdrant.delete(collection_name=COLLECTION, points_selector=ids)
|
||||||
|
_invalidate_meta()
|
||||||
log.info("cleanup: rimossi %d record scaduti", len(ids))
|
log.info("cleanup: rimossi %d record scaduti", len(ids))
|
||||||
except Exception as e: # noqa: BLE001
|
except Exception as e: # noqa: BLE001
|
||||||
log.warning("cleanup error: %s", e)
|
log.warning("cleanup error: %s", e)
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pi-qmem",
|
"name": "pi-qmem",
|
||||||
"version": "1.2.0",
|
"version": "1.3.0",
|
||||||
"description": "Memoria centralizzata e condivisa per agenti AI: salva e cerca record semantici (Qdrant + BGE-M3) via Memory Gateway.",
|
"description": "Memoria centralizzata e condivisa per agenti AI: salva e cerca record semantici (Qdrant + BGE-M3) via Memory Gateway.",
|
||||||
"keywords": ["pi-package", "memory", "agent", "qdrant", "rag"],
|
"keywords": ["pi-package", "memory", "agent", "qdrant", "rag"],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
Reference in New Issue
Block a user