a1d7a4b6b9
Legt semantisch gleiche Fakten zusammen und loest Widersprueche (neuester gewinnt) ueber /graph-Cluster + fast-Hirn (NoThink). Harte Leitplanken: pro Gruppe ueberlebt immer ein Fakt, max 1 Update, LLM-Fehler = Gruppe unangetastet, Kappung pro Lauf. Erster Live-Lauf 19.07.: 459 -> 420 Fakten (15 zusammengefasst, 39 geloescht). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
148 lines
6.1 KiB
Python
148 lines
6.1 KiB
Python
#!/usr/bin/env python3
|
|
"""mem0-Konsolidierung (monatlich, 19.07.2026 — Nachtrag Phase 2 Drei-Welten-Plan).
|
|
|
|
mem0 OSS v2 ist ADD-only: Es legt Fakten an, loest aber Widersprueche nie auf und
|
|
legt Aehnliches nie zusammen — ohne Pflege waechst das Gedaechtnis unbegrenzt und
|
|
der Recall verrauscht. Der Steward-Dedupe faengt nur exakte Dubletten. Dieser Lauf
|
|
macht die fehlende Konsolidierung:
|
|
|
|
1. Cluster aehnlicher Fakten holen — ueber den bestehenden /graph-Endpunkt des
|
|
Sidecars (Kosinus der gespeicherten Embeddings, kein Re-Embedding, kein
|
|
Chroma-Zugriff noetig).
|
|
2. Je Cluster entscheidet das lokale Hirn (Alias `fast`, Thinking aus wie im
|
|
Sidecar): zusammenlegen / Widerspruch aufloesen (neuester Zeitstempel
|
|
gewinnt) / NICHTS tun.
|
|
3. Anwenden ueber die normalen Sidecar-Endpunkte (PUT/DELETE /memory/<id>).
|
|
|
|
Sicherheitsleitplanken (hart im Code, nicht im Prompt):
|
|
- Pro Cluster ueberlebt IMMER mindestens ein Fakt.
|
|
- Nur ids aus dem Cluster, max. 1 Update pro Cluster, Inhalt <= 600 Zeichen.
|
|
- LLM-Fehler / kaputtes JSON => Cluster bleibt unangetastet.
|
|
- Kappung pro Lauf (MAX_CLUSTERS), damit ein Lauf nie das halbe Gedaechtnis
|
|
umpfluegt — der naechste Monat macht weiter.
|
|
|
|
Aufruf: mem0-konsolidierung.py [--apply] (Default: Dry-Run, druckt nur Plan)
|
|
"""
|
|
import json
|
|
import sys
|
|
import time
|
|
import urllib.request
|
|
|
|
MEM0 = "http://127.0.0.1:8765"
|
|
LLM = "http://127.0.0.1:8080/v1/chat/completions"
|
|
LLM_MODEL = "fast"
|
|
MIN_SCORE = 0.82
|
|
MAX_CLUSTERS = 15
|
|
MAX_FACTS_PER_CLUSTER = 6
|
|
APPLY = "--apply" in sys.argv
|
|
|
|
|
|
def _http(method: str, url: str, body: dict | None = None, timeout: float = 120.0):
|
|
data = json.dumps(body).encode() if body is not None else None
|
|
req = urllib.request.Request(url, data=data, method=method,
|
|
headers={"Content-Type": "application/json"})
|
|
with urllib.request.urlopen(req, timeout=timeout) as r:
|
|
return json.loads(r.read().decode() or "{}")
|
|
|
|
|
|
def clusters_from_graph() -> tuple[dict, list]:
|
|
g = _http("GET", f"{MEM0}/graph?min_score={MIN_SCORE}&top_k=5", timeout=180)
|
|
nodes = {n["id"]: n for n in g.get("nodes", [])}
|
|
parent: dict = {}
|
|
|
|
def find(x):
|
|
parent.setdefault(x, x)
|
|
while parent[x] != x:
|
|
parent[x] = parent[parent[x]]
|
|
x = parent[x]
|
|
return x
|
|
|
|
for e in g.get("edges", []):
|
|
a, b = find(e["source"]), find(e["target"])
|
|
if a != b:
|
|
parent[a] = b
|
|
groups: dict = {}
|
|
for nid in parent:
|
|
groups.setdefault(find(nid), []).append(nid)
|
|
clusters = [sorted(v) for v in groups.values() if len(v) >= 2]
|
|
clusters.sort(key=len, reverse=True)
|
|
return nodes, clusters[:MAX_CLUSTERS]
|
|
|
|
|
|
def judge(facts: list[dict]) -> list[dict]:
|
|
"""LLM entscheidet pro Cluster. Fehler => [] (nichts tun)."""
|
|
listing = "\n".join(
|
|
f"- id={f['id']} | kategorie={f.get('category')} | stand={f.get('updated_at', '')[:10]} | {f.get('content', '')}"
|
|
for f in facts)
|
|
system = (
|
|
"Du konsolidierst ein Langzeitgedaechtnis. Unten steht EINE Gruppe semantisch "
|
|
"aehnlicher Fakten. Entscheide konservativ: "
|
|
"(a) Sind es Facetten DESSELBEN Fakts: fasse sie in EINEM praezisen deutschen Satz "
|
|
"zusammen -> genau ein update auf die beste id, die anderen delete. "
|
|
"(b) Widersprechen sie sich: der Fakt mit dem NEUESTEN Stand gewinnt -> veraltete delete. "
|
|
"(c) Sind es VERSCHIEDENE Fakten (nur thematisch verwandt): TUE NICHTS. "
|
|
"Im Zweifel IMMER (c). Antworte NUR als JSON: "
|
|
'{"actions":[{"op":"update","id":"...","content":"..."},{"op":"delete","id":"..."}]} '
|
|
'oder {"actions":[]}.'
|
|
)
|
|
try:
|
|
resp = _http("POST", LLM, {
|
|
"model": LLM_MODEL,
|
|
"messages": [{"role": "system", "content": system},
|
|
{"role": "user", "content": "Fakten-Gruppe:\n" + listing}],
|
|
"temperature": 0.1, "max_tokens": 800,
|
|
"response_format": {"type": "json_object"},
|
|
"chat_template_kwargs": {"enable_thinking": False},
|
|
})
|
|
raw = resp["choices"][0]["message"]["content"] or "{}"
|
|
actions = json.loads(raw).get("actions", [])
|
|
except Exception as exc:
|
|
print(f" (LLM-Fehler, Gruppe unangetastet: {exc})")
|
|
return []
|
|
# Harte Validierung — Leitplanken siehe Docstring.
|
|
ids = {f["id"] for f in facts}
|
|
updates = [a for a in actions if a.get("op") == "update" and a.get("id") in ids
|
|
and isinstance(a.get("content"), str) and 0 < len(a["content"]) <= 600]
|
|
deletes = [a for a in actions if a.get("op") == "delete" and a.get("id") in ids]
|
|
if len(updates) > 1:
|
|
return []
|
|
del_ids = {a["id"] for a in deletes} - {a["id"] for a in updates}
|
|
if len(del_ids) >= len(ids):
|
|
return []
|
|
return updates + [{"op": "delete", "id": i} for i in sorted(del_ids)]
|
|
|
|
|
|
def main() -> None:
|
|
t0 = time.time()
|
|
nodes, clusters = clusters_from_graph()
|
|
total = len(nodes)
|
|
print(f"mem0-Konsolidierung {'(APPLY)' if APPLY else '(DRY-RUN)'} — "
|
|
f"{total} Fakten, {len(clusters)} Gruppen >= {MIN_SCORE}")
|
|
n_upd = n_del = 0
|
|
for ci, cl in enumerate(clusters, 1):
|
|
facts = [nodes[i] for i in cl][:MAX_FACTS_PER_CLUSTER]
|
|
actions = judge(facts)
|
|
if not actions:
|
|
continue
|
|
print(f"Gruppe {ci} ({len(facts)} Fakten):")
|
|
for f in facts:
|
|
print(f" [{f['id'][:8]}] {f.get('content', '')[:90]}")
|
|
for a in actions:
|
|
if a["op"] == "update":
|
|
print(f" -> UPDATE {a['id'][:8]}: {a['content'][:90]}")
|
|
n_upd += 1
|
|
if APPLY:
|
|
_http("PUT", f"{MEM0}/memory/{a['id']}", {"content": a["content"]})
|
|
else:
|
|
print(f" -> DELETE {a['id'][:8]}")
|
|
n_del += 1
|
|
if APPLY:
|
|
_http("DELETE", f"{MEM0}/memory/{a['id']}")
|
|
print(f"Ergebnis: {n_upd} zusammengefasst, {n_del} geloescht, "
|
|
f"{total - (n_del if APPLY else 0)} Fakten verbleiben. "
|
|
f"({int(time.time() - t0)} s)")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|