8a2db70f3c
c) System-Logs ausgebaut: neue LogConsole-Komponente färbt Fehler (rot) und Warnungen (amber) ein, "Nur Probleme"-Filter mit Zähler, Zeilen-Suche; voice-service in die Dienst-Liste aufgenommen (war nur backend-seitig). e) Mem0-Dubletten automatisch: deterministischer Auto-Dedupe-Loop (täglich, apply=True, Schwelle 0.9 > manueller 0.85 da ohne Review) als Backend- Hintergrund-Task — kein Memory-Bloat mehr ohne Zutun. Knopf bleibt on-demand. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
178 lines
7.2 KiB
Python
178 lines
7.2 KiB
Python
"""
|
|
Geteiltes Gedächtnis (die „Verfassung") — jetzt auto-lernend & semantisch über Mem0.
|
|
|
|
Dieser Service ist nur noch ein dünner HTTP-Client auf den Mem0-Sidecar (mem0_service/app.py,
|
|
läuft im ~/.mem0/venv unter Python 3.12). Die `/api/memory`-API-Form bleibt unverändert, damit
|
|
UI und MCP-Server kompatibel bleiben. Neu gegenüber der alten flachen SQLite:
|
|
|
|
- search (q gesetzt) ist SEMANTISCH (Vektor/Embeddings) statt LIKE-Textsuche, mit Relevanz-Score.
|
|
- learn() reicht Gesprächs-Turns durch → Mem0 EXTRAHIERT Fakten selbst (Auto-Lernen).
|
|
- Dedup macht Mem0 beim Auto-Lernen selbst; der manuelle Kurator unten bleibt als Komfort.
|
|
|
|
5 Kategorien (user · instruction · stable · versioned · ephemeral) bleiben als Metadaten erhalten.
|
|
"""
|
|
|
|
import asyncio
|
|
import logging
|
|
import os
|
|
import re
|
|
from difflib import SequenceMatcher
|
|
|
|
import httpx
|
|
|
|
from config import MEM0_SERVICE_URL
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
CATEGORIES = ("identity", "knowledge", "rules", "events")
|
|
|
|
_TIMEOUT = httpx.Timeout(60.0, connect=5.0) # LLM-Extraktion kann ein paar Sekunden dauern
|
|
|
|
|
|
def _get(path: str, **params) -> list | dict:
|
|
r = httpx.get(f"{MEM0_SERVICE_URL}{path}", params=params, timeout=_TIMEOUT)
|
|
r.raise_for_status()
|
|
return r.json()
|
|
|
|
|
|
def _post(path: str, data: dict) -> dict:
|
|
r = httpx.post(f"{MEM0_SERVICE_URL}{path}", json=data, timeout=_TIMEOUT)
|
|
r.raise_for_status()
|
|
return r.json()
|
|
|
|
|
|
def _put(path: str, data: dict) -> dict:
|
|
r = httpx.put(f"{MEM0_SERVICE_URL}{path}", json=data, timeout=_TIMEOUT)
|
|
r.raise_for_status()
|
|
return r.json()
|
|
|
|
|
|
def _delete(path: str) -> dict:
|
|
r = httpx.delete(f"{MEM0_SERVICE_URL}{path}", timeout=_TIMEOUT)
|
|
r.raise_for_status()
|
|
return r.json()
|
|
|
|
|
|
def list_memories(q: str = "", category: str = "") -> list[dict]:
|
|
"""Alle Fakten oder — wenn q gesetzt — die semantisch ähnlichsten (mit `score`)."""
|
|
return _get("/memory", **{k: v for k, v in (("q", q), ("category", category)) if v})
|
|
|
|
|
|
def add_memory(content: str, category: str = "stable", source: str = "manual") -> dict:
|
|
"""Einen Fakt VERBATIM speichern (keine LLM-Umformung). Auto-Lernen → learn()."""
|
|
return _post("/memory", {"content": content.strip(), "category": category, "source": source})
|
|
|
|
|
|
def update_memory(mid: str, content: str | None = None, category: str | None = None) -> dict | None:
|
|
try:
|
|
return _put(f"/memory/{mid}", {"content": content, "category": category})
|
|
except httpx.HTTPStatusError as exc:
|
|
if exc.response.status_code == 404:
|
|
return None
|
|
raise
|
|
|
|
|
|
def delete_memory(mid: str) -> bool:
|
|
try:
|
|
_delete(f"/memory/{mid}")
|
|
return True
|
|
except httpx.HTTPStatusError as exc:
|
|
if exc.response.status_code == 404:
|
|
return False
|
|
raise
|
|
|
|
|
|
def learn(text: str | None = None, messages: list[dict] | None = None,
|
|
source: str = "auto", category: str = "stable") -> dict:
|
|
"""Auto-Lernen: Text/Gesprächs-Turns durchreichen → Mem0 extrahiert die Fakten selbst."""
|
|
return _post("/learn", {"text": text, "messages": messages,
|
|
"source": source, "category": category})
|
|
|
|
|
|
def graph(min_score: float = 0.45, top_k: int = 3) -> dict:
|
|
"""Fakten als Ähnlichkeits-Graph (Knoten + semantische Kanten) für die UI-Visualisierung."""
|
|
return _get("/graph", min_score=min_score, top_k=top_k)
|
|
|
|
|
|
def export_text() -> dict:
|
|
rows = sorted(list_memories(), key=lambda r: (r.get("category", ""), r.get("updated_at", "")))
|
|
lines = ["# Mission Control — Gedächtnis\n"]
|
|
current = ""
|
|
for r in rows:
|
|
if r.get("category") != current:
|
|
current = r.get("category", "")
|
|
lines.append(f"\n## {current}\n")
|
|
src = r.get("source", "")
|
|
when = (r.get("updated_at") or "")[:10]
|
|
lines.append(f"- {r.get('content', '')} _(Quelle: {src}, {when})_")
|
|
return {"text": "\n".join(lines), "count": len(rows)}
|
|
|
|
|
|
# --- Manueller Kurator (deterministisch, kein LLM) ---------------------------
|
|
def _norm(s: str) -> str:
|
|
s = re.sub(r"[^\w\s]", " ", s.lower(), flags=re.UNICODE)
|
|
return re.sub(r"\s+", " ", s).strip()
|
|
|
|
|
|
def dedupe(apply: bool = False, threshold: float = 0.85) -> dict:
|
|
"""Findet Dubletten (exakt/enthalten/ähnlich) je Kategorie, behält den längsten
|
|
Eintrag. Mem0 dedupliziert beim Auto-Lernen schon semantisch — das hier ist der
|
|
manuelle Komfort-Knopf fürs UI (z.B. nach vielen Verbatim-Importen)."""
|
|
rows = sorted(list_memories(), key=lambda r: (-len(r.get("content", "")), r.get("created_at", "")))
|
|
used: set[str] = set()
|
|
groups: list[dict] = []
|
|
for i, a in enumerate(rows):
|
|
if a["id"] in used:
|
|
continue
|
|
na = _norm(a.get("content", ""))
|
|
if not na:
|
|
continue
|
|
dups = []
|
|
for b in rows[i + 1:]:
|
|
if b["id"] in used or b.get("category") != a.get("category"):
|
|
continue
|
|
nb = _norm(b.get("content", ""))
|
|
if not nb:
|
|
continue
|
|
if nb in na or na in nb or SequenceMatcher(None, na, nb).ratio() >= threshold:
|
|
dups.append(b); used.add(b["id"])
|
|
if dups:
|
|
used.add(a["id"])
|
|
groups.append({
|
|
"keep": {"id": a["id"], "content": a.get("content"), "category": a.get("category")},
|
|
"remove": [{"id": d["id"], "content": d.get("content")} for d in dups],
|
|
})
|
|
dup_count = sum(len(g["remove"]) for g in groups)
|
|
removed = 0
|
|
if apply:
|
|
for g in groups:
|
|
for d in g["remove"]:
|
|
if delete_memory(d["id"]):
|
|
removed += 1
|
|
return {"groups": groups, "duplicate_count": dup_count, "removed": removed, "applied": apply}
|
|
|
|
|
|
# ── Auto-Dedupe (D16e): Dubletten von selbst wegräumen, kein Knopf-Zwang ─────────────
|
|
# Deterministisch in UNSERER Schicht (wie reminders, Verdikt 11c) statt am Hermes-cron —
|
|
# läuft als Hintergrund-Task. Höhere Schwelle als der manuelle Knopf (0.9 statt 0.85), weil
|
|
# OHNE Mensch-Review angewandt wird: lieber eine Dublette stehen lassen als etwas Distinktes
|
|
# löschen. Mem0 dedupliziert beim Auto-Lernen schon semantisch — das hier fängt den Rest
|
|
# (Verbatim-Importe, wiederholte Fakten) ab, damit das Gedächtnis nicht ohne Zutun aufbläht.
|
|
AUTO_DEDUPE_ENABLED = os.environ.get("MC_MEM_DEDUPE_ENABLED", "1") != "0"
|
|
AUTO_DEDUPE_INTERVAL = int(os.environ.get("MC_MEM_DEDUPE_INTERVAL", str(24 * 3600))) # täglich
|
|
AUTO_DEDUPE_START_DELAY = int(os.environ.get("MC_MEM_DEDUPE_START_DELAY", "300")) # 5 min nach Start
|
|
AUTO_DEDUPE_THRESHOLD = float(os.environ.get("MC_MEM_DEDUPE_THRESHOLD", "0.9"))
|
|
|
|
|
|
async def auto_dedupe_loop() -> None:
|
|
"""Hintergrund-Task: räumt periodisch Gedächtnis-Dubletten weg (apply=True)."""
|
|
await asyncio.sleep(AUTO_DEDUPE_START_DELAY) # Mem0-Sidecar nach Start hochkommen lassen
|
|
while True:
|
|
try:
|
|
res = await asyncio.to_thread(dedupe, True, AUTO_DEDUPE_THRESHOLD) # sync HTTP → Thread
|
|
if res.get("removed"):
|
|
log.info("mem-dedupe: %d Dublette(n) automatisch entfernt", res["removed"])
|
|
except Exception:
|
|
log.debug("mem-dedupe: Lauf fehlgeschlagen", exc_info=True)
|
|
await asyncio.sleep(AUTO_DEDUPE_INTERVAL)
|