feat: supersede di memorie false (qmem_correct, include_superseded, playbook sez. 11)

- gateway: supersedes_id validato (404/409), backlink superseded_by+superseded_at+supersede_reason sul vecchio record, indici keyword, search esclude i superseduti di default (include_superseded per lineage), risposta con lineage
- estensione: tool qmem_correct (memory_id o query), qmem_store con supersedes_id/supersede_reason, qmem_search con include_superseded
- README/playbook aggiornati, versione 1.1.0
This commit is contained in:
enne2
2026-08-13 12:41:14 +02:00
parent eadd2a756b
commit 1713e68b82
5 changed files with 513 additions and 12 deletions
+40 -7
View File
@@ -8,8 +8,8 @@ agente (attuale o futuro) con la chiave può consultare e aggiungere
informazioni liberamente. L'agent_id è solo metadata di provenienza.
Endpoints:
POST /v1/memories → crea un record di memoria
POST /v1/memories:search → ricerca semantica con filtri
POST /v1/memories → crea un record (con supersedes_id corregge un record esistente)
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/status → health + statistiche
@@ -48,7 +48,7 @@ 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.0.0")
app = FastAPI(title="Memory Gateway", version="2.1.0")
qdrant = QdrantClient(url=QDRANT_URL, api_key=QDRANT_API_KEY)
# Rate limit in-memory: {key: [timestamps]}
@@ -67,6 +67,7 @@ class MemoryIn(BaseModel):
source: Optional[str] = Field(default=None, max_length=256)
expires_at: Optional[str] = None # ISO 8601
supersedes_id: Optional[str] = None
supersede_reason: Optional[str] = Field(default=None, max_length=512)
class SearchIn(BaseModel):
@@ -74,6 +75,7 @@ class SearchIn(BaseModel):
kind: Optional[Literal["decision", "fact", "episode", "preference"]] = None
project_id: Optional[str] = None
scope: Optional[Literal["agent", "project", "org"]] = None
include_superseded: bool = False
top_k: int = Field(default=5, ge=1, le=20)
@@ -142,7 +144,7 @@ def startup() -> None:
collection_name=COLLECTION,
vectors_config=qm.VectorParams(size=EMBED_DIM, distance=qm.Distance.COSINE),
)
for field in ("agent_id", "project_id", "scope", "kind"):
for field in ("agent_id", "project_id", "scope", "kind", "supersedes_id", "superseded_by"):
qdrant.create_payload_index(
collection_name=COLLECTION,
field_name=field,
@@ -159,6 +161,17 @@ def startup() -> None:
@app.post("/v1/memories")
async def add_memory(body: MemoryIn, key: str = Depends(require_auth)) -> dict:
memory_id = str(uuid.uuid4())
# Supersede: il nuovo record corregge uno esistente, che resta in archivio marcato
superseded_id: Optional[str] = None
if body.supersedes_id:
old = 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 embed(body.text)
payload: dict[str, Any] = {
@@ -170,15 +183,29 @@ async def add_memory(body: MemoryIn, key: str = Depends(require_auth)) -> dict:
"source": body.source,
"created_at": _now_iso(),
"expires_at": _parse_ts(body.expires_at),
"supersedes_id": body.supersedes_id,
"supersedes_id": superseded_id,
"supersede_reason": body.supersede_reason,
"embedding_model": EMBED_MODEL,
}
qdrant.upsert(
collection_name=COLLECTION,
points=[qm.PointStruct(id=memory_id, vector=vector, payload=payload)],
)
_audit(key, "create", memory_id=memory_id, kind=body.kind, agent_id=payload["agent_id"])
return {"memory_id": memory_id, "created_at": payload["created_at"]}
if superseded_id:
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"])
else:
_audit(key, "create", memory_id=memory_id, kind=body.kind, agent_id=payload["agent_id"])
return {"memory_id": memory_id, "created_at": payload["created_at"], "supersedes_id": superseded_id}
@app.post("/v1/memories:search")
@@ -192,6 +219,9 @@ async def search_memories(body: SearchIn, key: str = Depends(require_auth)) -> d
must.append(qm.FieldCondition(key="project_id", match=qm.MatchValue(value=body.project_id)))
if body.scope:
must.append(qm.FieldCondition(key="scope", match=qm.MatchValue(value=body.scope)))
if not body.include_superseded:
# default: esclude i record già corretti (superseded_by presente)
must.append(qm.IsEmptyCondition(is_empty=qm.PayloadField(key="superseded_by")))
hits = qdrant.search(
collection_name=COLLECTION,
@@ -211,6 +241,9 @@ async def search_memories(body: SearchIn, key: str = Depends(require_auth)) -> d
"project_id": h.payload.get("project_id"),
"created_at": h.payload.get("created_at"),
"source": h.payload.get("source"),
"supersedes_id": h.payload.get("supersedes_id"),
"superseded_by": h.payload.get("superseded_by"),
"supersede_reason": h.payload.get("supersede_reason"),
}
for h in hits
]