feat(reparenting): supersede di un record ri-parenta automaticamente i figli attivi

- gateway: nel blocco supersede, i figli attivi (parent_id == vecchio UUID,
  superseded_by vuoto) vengono ri-parentati al nuovo UUID; audit action
  'reparent'; risposta con campo 'reparented'. Un solo livello: i nipoti
  puntano agli UUID dei figli, invariati. I figli superseduti restano
  storici ancorati alla vecchia lineage.
- estensione: qmem_tree con fallback lineage (cerca figli anche per
  supersedes_id del root) per gli orfani pre-fix; qmem_correct riporta
  il numero di figli ri-parentati.
- test: 2 nuovi (ri-parenta figli attivi; ri-parenta solo attivi con
  figlio già superseduto). 34 pass.
- deploy su brain (10.8.0.3): main.py aggiornato, container ricreato,
  smoke test end-to-end OK (reparented=1).
This commit is contained in:
Matteo Benedetto
2026-08-24 15:43:33 +02:00
parent 0283d3964e
commit e68fca3388
4 changed files with 160 additions and 14 deletions
+18 -2
View File
@@ -70,8 +70,24 @@ class FakeQdrant:
for pid in points_selector:
self.points.pop(pid, None)
def scroll(self, collection_name, **kw):
return list(self.points.values()), None
def _matches(self, pl, query_filter):
"""Applica i filtri metadata (FieldCondition match / IsEmptyCondition)."""
if not query_filter or not query_filter.must:
return True
for cond in query_filter.must:
if hasattr(cond, "key") and hasattr(cond, "match"):
if pl.get(cond.key) != cond.match.value:
return False
elif hasattr(cond, "is_empty"):
if pl.get(cond.is_empty.key):
return False
return True
def scroll(self, collection_name, scroll_filter=None, limit=None, with_payload=True, **kw):
points = [p for p in self.points.values() if self._matches(p.payload, scroll_filter)]
if limit:
points = points[:limit]
return points, None
def get_collection(self, collection_name):
class _Info:
+85
View File
@@ -143,6 +143,91 @@ def test_supersede_doppio_409(client):
assert r.status_code == 409
def test_supersede_root_ri_parenta_figli_attivi(client):
# L1 root + figlio L2
r1 = client.post(
"/v1/memories",
json=make_record(text="root L1", level="L1_ROOT", topic="TEST-TOPIC/ROOT"),
headers=auth_headers(),
)
root_id = r1.json()["memory_id"]
r2 = client.post(
"/v1/memories",
json=make_record(text="figlio L2", level="L2_SUBTOPIC", topic="TEST-TOPIC/SUB", parent_id=root_id),
headers=auth_headers(),
)
child_id = r2.json()["memory_id"]
# supersede il root
r3 = client.post(
"/v1/memories",
json=make_record(
text="root L1 corretto",
level="L1_ROOT",
topic="TEST-TOPIC/ROOT",
supersedes_id=root_id,
supersede_reason="aggiornamento",
),
headers=auth_headers(),
)
assert r3.status_code == 200
new_root_id = r3.json()["memory_id"]
assert r3.json()["reparented"] == 1
# il figlio attivo ora punta al nuovo root
child = client.get(f"/v1/memories/{child_id}", headers=auth_headers()).json()
assert child["parent_id"] == new_root_id
# search per parent_id sul nuovo root trova il figlio
s = client.post(
"/v1/memories:search",
json={"query": "*", "parent_id": new_root_id, "top_k": 10, "min_score": 0.0},
headers=auth_headers(),
)
ids = [x["memory_id"] for x in s.json()["results"]]
assert child_id in ids
def test_supersede_root_ri_parenta_solo_figli_attivi(client):
# L1 root + figlio L2 + figlio L2 già superseduto (versione attiva C1')
r1 = client.post(
"/v1/memories",
json=make_record(text="root", level="L1_ROOT", topic="T2/ROOT"),
headers=auth_headers(),
)
root_id = r1.json()["memory_id"]
c1 = client.post(
"/v1/memories",
json=make_record(text="figlio vecchio", level="L2_SUBTOPIC", topic="T2/SUB", parent_id=root_id),
headers=auth_headers(),
)
c1_id = c1.json()["memory_id"]
c1p = client.post(
"/v1/memories",
json=make_record(
text="figlio nuovo",
level="L2_SUBTOPIC",
topic="T2/SUB",
parent_id=root_id,
supersedes_id=c1_id,
),
headers=auth_headers(),
)
c1p_id = c1p.json()["memory_id"]
r2 = client.post(
"/v1/memories",
json=make_record(text="root corretto", level="L1_ROOT", topic="T2/ROOT", supersedes_id=root_id),
headers=auth_headers(),
)
new_root_id = r2.json()["memory_id"]
assert r2.json()["reparented"] == 1 # solo C1' (attivo)
# C1' ri-parentato al nuovo root; C1 storico resta ancorato al vecchio
assert client.get(f"/v1/memories/{c1p_id}", headers=auth_headers()).json()["parent_id"] == new_root_id
assert client.get(f"/v1/memories/{c1_id}", headers=auth_headers()).json()["parent_id"] == root_id
# ---------------------------------------------------------------------------
# Ricerca e filtri
# ---------------------------------------------------------------------------