Feat: Agent-Memory auf Mem0 (auto-lernend, semantisch) via Sidecar
Paket A des Plans. Ersetzt die flache SQLite-Fakten-DB durch Mem0 (LLM-Auto-
Extraktion + Vektor/Chroma-Suche). Architektur erzwungen durch Python-Split:
MC2-Backend laeuft auf 3.14 (kann mem0 nicht importieren), mem0+chromadb nur
auf 3.12 (~/.mem0/venv) -> Mem0-Sidecar (FastAPI, localhost:8765), MC2 spricht
ihn per HTTP. /api/memory-Form bleibt unveraendert (UI + MCP kompatibel).
- mem0_service/: Sidecar (app.py), Migration (migrate.py), deps.
- Embeddings: neue llama-swap embed-Rolle (Qwen3-Embedding-0.6B, 1024 Dim,
pooling last) ueber /v1/embeddings.
- LLM-Extraktion: lokales fast-Hirn; NoThinkLLM schaltet Qwen3-Thinking ab
(sonst bricht json_object-Extraktion ab), custom_instructions halten Deutsch.
- backend/services/memory.py: duenner HTTP-Client auf den Sidecar (semantische
Suche mit score, verbatim add, learn()). Router: /api/memory/learn.
- mcp/mcp_memory.py: neues learn-Tool (Auto-Lernen aus Gespraechs-Turns),
search jetzt semantisch.
- Frontend: Relevanz-Score + Auto/Manuell-Herkunft im Gedaechtnis-Tab.
- deploy/: mem0-service.service + deploy.sh (uv-Install, Migration, Restart).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+77
-84
@@ -1,133 +1,127 @@
|
||||
"""
|
||||
Geteiltes Gedächtnis (die „Verfassung") — SQLite aus stdlib, WAL-Mode.
|
||||
Portiert aus Mission Control v1 (routers/memory.py), DB-Logik als Service isoliert.
|
||||
Geteiltes Gedächtnis (die „Verfassung") — jetzt auto-lernend & semantisch über Mem0.
|
||||
|
||||
5 Kategorien: user · instruction · stable · versioned · ephemeral (7-Tage-TTL).
|
||||
Dedupe = deterministischer Kurator (exakt/enthalten/ähnlich), KEIN LLM.
|
||||
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 re
|
||||
import sqlite3
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from difflib import SequenceMatcher
|
||||
|
||||
from config import MEMORY_DB
|
||||
import httpx
|
||||
|
||||
from config import MEM0_SERVICE_URL
|
||||
|
||||
CATEGORIES = ("user", "instruction", "stable", "versioned", "ephemeral")
|
||||
|
||||
_conn: sqlite3.Connection | None = None
|
||||
_TIMEOUT = httpx.Timeout(60.0, connect=5.0) # LLM-Extraktion kann ein paar Sekunden dauern
|
||||
|
||||
|
||||
def db() -> sqlite3.Connection:
|
||||
global _conn
|
||||
if _conn is None:
|
||||
MEMORY_DB.parent.mkdir(parents=True, exist_ok=True)
|
||||
_conn = sqlite3.connect(str(MEMORY_DB), check_same_thread=False)
|
||||
_conn.row_factory = sqlite3.Row
|
||||
_conn.execute("PRAGMA journal_mode=WAL")
|
||||
_conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS memories (
|
||||
id TEXT PRIMARY KEY,
|
||||
content TEXT NOT NULL,
|
||||
category TEXT NOT NULL DEFAULT 'stable',
|
||||
source TEXT NOT NULL DEFAULT 'manual',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)
|
||||
""")
|
||||
_conn.execute(
|
||||
"DELETE FROM memories WHERE category='ephemeral'"
|
||||
" AND datetime(created_at) < datetime('now','-7 days')"
|
||||
)
|
||||
_conn.commit()
|
||||
return _conn
|
||||
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 _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
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]:
|
||||
sql, params, conds = "SELECT * FROM memories", [], []
|
||||
if q:
|
||||
conds.append("content LIKE ?"); params.append(f"%{q}%")
|
||||
if category:
|
||||
conds.append("category = ?"); params.append(category)
|
||||
if conds:
|
||||
sql += " WHERE " + " AND ".join(conds)
|
||||
sql += " ORDER BY created_at DESC"
|
||||
return [dict(r) for r in db().execute(sql, params).fetchall()]
|
||||
"""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:
|
||||
now, mid = _now(), str(uuid.uuid4())
|
||||
db().execute(
|
||||
"INSERT INTO memories (id,content,category,source,created_at,updated_at) VALUES (?,?,?,?,?,?)",
|
||||
(mid, content.strip(), category, source, now, now),
|
||||
)
|
||||
db().commit()
|
||||
return {"id": mid, "content": content.strip(), "category": category,
|
||||
"source": source, "created_at": now, "updated_at": now}
|
||||
"""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:
|
||||
row = db().execute("SELECT * FROM memories WHERE id=?", (mid,)).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
now = _now()
|
||||
new_content = content.strip() if content is not None else row["content"]
|
||||
new_cat = category if category is not None else row["category"]
|
||||
db().execute("UPDATE memories SET content=?,category=?,updated_at=? WHERE id=?",
|
||||
(new_content, new_cat, now, mid))
|
||||
db().commit()
|
||||
return {"id": mid, "content": new_content, "category": new_cat,
|
||||
"source": row["source"], "created_at": row["created_at"], "updated_at": now}
|
||||
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:
|
||||
if not db().execute("SELECT id FROM memories WHERE id=?", (mid,)).fetchone():
|
||||
return False
|
||||
db().execute("DELETE FROM memories WHERE id=?", (mid,))
|
||||
db().commit()
|
||||
return True
|
||||
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 export_text() -> dict:
|
||||
rows = db().execute("SELECT * FROM memories ORDER BY category, updated_at DESC").fetchall()
|
||||
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["category"] != current:
|
||||
lines.append(f"\n## {r['category']}\n")
|
||||
current = r["category"]
|
||||
lines.append(f"- {r['content']} _(Quelle: {r['source']}, {r['updated_at'][:10]})_")
|
||||
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:
|
||||
"""Deterministischer Kurator: findet Dubletten (exakt/enthalten/ähnlich) je Kategorie,
|
||||
behält den vollständigsten (längsten) Eintrag. Konservativ, kein LLM."""
|
||||
rows = [dict(r) for r in db().execute(
|
||||
"SELECT * FROM memories ORDER BY length(content) DESC, created_at ASC").fetchall()]
|
||||
"""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["content"])
|
||||
na = _norm(a.get("content", ""))
|
||||
if not na:
|
||||
continue
|
||||
dups = []
|
||||
for b in rows[i + 1:]:
|
||||
if b["id"] in used or b["category"] != a["category"]:
|
||||
if b["id"] in used or b.get("category") != a.get("category"):
|
||||
continue
|
||||
nb = _norm(b["content"])
|
||||
nb = _norm(b.get("content", ""))
|
||||
if not nb:
|
||||
continue
|
||||
if nb in na or na in nb or SequenceMatcher(None, na, nb).ratio() >= threshold:
|
||||
@@ -135,15 +129,14 @@ def dedupe(apply: bool = False, threshold: float = 0.85) -> dict:
|
||||
if dups:
|
||||
used.add(a["id"])
|
||||
groups.append({
|
||||
"keep": {"id": a["id"], "content": a["content"], "category": a["category"]},
|
||||
"remove": [{"id": d["id"], "content": d["content"]} for d in dups],
|
||||
"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"]:
|
||||
db().execute("DELETE FROM memories WHERE id=?", (d["id"],)); removed += 1
|
||||
if removed:
|
||||
db().commit()
|
||||
if delete_memory(d["id"]):
|
||||
removed += 1
|
||||
return {"groups": groups, "duplicate_count": dup_count, "removed": removed, "applied": apply}
|
||||
|
||||
Reference in New Issue
Block a user