From 084dd73f710adb4b72b8c0c2c0af64a9a38ac7a1 Mon Sep 17 00:00:00 2001 From: Hitonabi Date: Sun, 23 Aug 2026 21:27:32 +0200 Subject: [PATCH] =?UTF-8?q?fix(deploy):=20Mem0-Logik=20aus=20Backup/Restor?= =?UTF-8?q?e/Postchecks/Venv-Audit/Hooks=20entfernt,=20mem0=5Fservice/=20g?= =?UTF-8?q?el=C3=B6scht?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/agent-hooks/box-steckbrief-inject.sh | 2 +- deploy/autoupdate.sh | 2 +- deploy/backup.sh | 15 +- deploy/hermes-postcheck.sh | 15 +- deploy/restore.sh | 5 +- deploy/stack-postcheck.sh | 16 +- deploy/venv-audit.sh | 4 +- deploy/warmup.sh | 2 +- mem0_service/app.py | 483 -------------------- mem0_service/migrate.py | 62 --- mem0_service/requirements.txt | 9 - mem0_service/smoke_test.py | 71 --- 12 files changed, 17 insertions(+), 669 deletions(-) delete mode 100644 mem0_service/app.py delete mode 100644 mem0_service/migrate.py delete mode 100644 mem0_service/requirements.txt delete mode 100644 mem0_service/smoke_test.py diff --git a/deploy/agent-hooks/box-steckbrief-inject.sh b/deploy/agent-hooks/box-steckbrief-inject.sh index 7b02758..cd7ca45 100644 --- a/deploy/agent-hooks/box-steckbrief-inject.sh +++ b/deploy/agent-hooks/box-steckbrief-inject.sh @@ -28,7 +28,7 @@ hint="" # Stabile Anti-Flail-Orientierung: WER bin ich, WO laufen die Modelle, WAS ist tabu. ctx="$(cat <.tar.gz, behält die letzten N. # Läuft per systemd-Timer (täglich), manuell, oder über den UI-Snapshot-Button. # -# Inhalt: mem0 (Chroma + history.db) · ~/.hermes (config.yaml, .env, plugins/) · -# /etc/llama-swap/config.yaml · known-good/ (Versions-Manifest, s. u.) +# Inhalt: ~/.hermes (config.yaml, .env, plugins/) · /etc/llama-swap/config.yaml · +# known-good/ (Versions-Manifest, s. u.) # NICHT enthalten (bewusst): GGUF-Modelle (riesig, neu ladbar), MC2-Code (Git), venvs. # # ACHTUNG: das Tarball enthält ~/.hermes/.env (Secrets) → chmod 600, nicht in Git. @@ -20,16 +20,14 @@ RETAIN="${MC_BACKUP_RETAIN:-14}" # Off-Box-Ziel (leer = deaktiviert). Default: Proxmox-Host, eigener Key mit minimalem Recht. OFFSITE="${MC_BACKUP_OFFSITE:-root@192.168.178.108:/var/lib/vz/mc2-backups}" OFFSITE_KEY="${MC_BACKUP_OFFSITE_KEY:-$HOME/.ssh/mc2_offsite}" -MEM0_DIR="${MC_MEM0_DIR:-/srv/models/mem0}" LSWAP="${MC_CONFIG_PATH:-/etc/llama-swap/config.yaml}" HERMES="${HERMES_HOME:-$HOME/.hermes}" TS="$(date +%Y%m%d-%H%M%S)" STAGE="$(mktemp -d)" trap 'rm -rf "$STAGE"' EXIT -mkdir -p "$STAGE/mem0" "$STAGE/hermes" "$STAGE/llama-swap" +mkdir -p "$STAGE/hermes" "$STAGE/llama-swap" -[ -d "$MEM0_DIR" ] && cp -a "$MEM0_DIR/." "$STAGE/mem0/" || true [ -f "$HERMES/config.yaml" ] && cp -a "$HERMES/config.yaml" "$STAGE/hermes/" || true [ -f "$HERMES/.env" ] && cp -a "$HERMES/.env" "$STAGE/hermes/" || true [ -d "$HERMES/plugins" ] && cp -a "$HERMES/plugins" "$STAGE/hermes/plugins" || true @@ -60,7 +58,7 @@ SRC="${MC2_SRC:-$HOME/mission-control-v2}" HERMES_AGENT="${HERMES_AGENT_DIR:-$HOME/.hermes/hermes-agent}" UV="$(command -v uv || echo "$HOME/.local/bin/uv")" -# pip freeze je venv; mem0-venv ist uv-managed (kein pip-Modul) → uv als Fallback. +# pip freeze je venv; voice-venv ist uv-managed (kein pip-Modul) → uv als Fallback. freeze_venv() { # $1 = python-Pfad, $2 = Zieldatei if [ ! -x "$1" ]; then echo "venv fehlt: $1" > "$2"; return 0; fi "$1" -m pip freeze > "$2" 2>/dev/null \ @@ -68,7 +66,6 @@ freeze_venv() { # $1 = python-Pfad, $2 = Zieldatei || echo "pip freeze fehlgeschlagen: $1" > "$2" } freeze_venv "$SRC/backend/.venv/bin/python" "$KG/pip-backend.txt" -freeze_venv "$HOME/.mem0/venv/bin/python" "$KG/pip-mem0.txt" freeze_venv "$HOME/.voice/venv/bin/python" "$KG/pip-voice.txt" { @@ -79,7 +76,7 @@ freeze_venv "$HOME/.voice/venv/bin/python" "$KG/pip-voice.txt" echo "llama.cpp : $(LD_LIBRARY_PATH=/opt/llamacpp-vulkan /usr/local/bin/llama-server --version 2>&1 | grep -m1 -o 'version: .*' || echo '?')" echo "llama-swap : $(/usr/local/bin/llama-swap --version 2>/dev/null | head -1 || echo '?')" echo "python-venvs :" - for P in "$SRC/backend/.venv/bin/python" "$HOME/.mem0/venv/bin/python" "$HOME/.voice/venv/bin/python"; do + for P in "$SRC/backend/.venv/bin/python" "$HOME/.voice/venv/bin/python"; do echo " $P → $("$P" --version 2>&1 || echo 'fehlt')" done } > "$KG/versions.txt" || true @@ -92,7 +89,7 @@ cat > "$STAGE/MANIFEST.txt" </dev/null 2>&1; then - echo "PASS · Mem0-Sidecar erreichbar" -else - echo "FAIL · Mem0-Sidecar NICHT erreichbar"; fail=1 -fi - -if curl -sf -m 8 "$MC_URL/api/memory" >/dev/null 2>&1; then - echo "PASS · /api/memory antwortet" -else - echo "FAIL · /api/memory antwortet nicht"; fail=1 -fi - if grep -qE "^[[:space:]]*provider:[[:space:]]*'?mc2-memory'?" "$HERMES/config.yaml" 2>/dev/null \ && grep -qE "memory_enabled:[[:space:]]*true" "$HERMES/config.yaml" 2>/dev/null; then echo "PASS · memory.provider=mc2-memory aktiv" diff --git a/deploy/restore.sh b/deploy/restore.sh index 7a839a5..cec8654 100644 --- a/deploy/restore.sh +++ b/deploy/restore.sh @@ -15,11 +15,10 @@ set -euo pipefail MODELS_DIR="${MC_MODELS_DIR:-/srv/models}" DEST_DIR="$MODELS_DIR/mc2-backups" -MEM0_DIR="${MC_MEM0_DIR:-/srv/models/mem0}" LSWAP="${MC_CONFIG_PATH:-/etc/llama-swap/config.yaml}" HERMES="${HERMES_HOME:-$HOME/.hermes}" SRC="${MC2_SRC:-$HOME/mission-control-v2}" -SERVICES="mem0-service mission-control-2 hermes-gateway" +SERVICES="mission-control-2 hermes-gateway" OFFSITE="${MC_BACKUP_OFFSITE:-root@192.168.178.108:/var/lib/vz/mc2-backups}" OFFSITE_KEY="${MC_BACKUP_OFFSITE_KEY:-$HOME/.ssh/mc2_offsite}" OFFSITE_SSH="ssh -i $OFFSITE_KEY -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new" @@ -98,7 +97,6 @@ echo "--- Inhalt ---"; cat "$STAGE/MANIFEST.txt" 2>/dev/null || true; echo "---- if [ "$DRY" = 1 ]; then echo "[dry-run] würde zurücksetzen:" - [ -d "$STAGE/mem0" ] && echo " • mem0 → $MEM0_DIR (wird ersetzt)" [ -f "$STAGE/hermes/config.yaml" ] && echo " • $HERMES/config.yaml" [ -f "$STAGE/hermes/.env" ] && echo " • $HERMES/.env" [ -d "$STAGE/hermes/plugins" ] && echo " • $HERMES/plugins/" @@ -119,7 +117,6 @@ bash "$SRC/deploy/backup.sh" || echo " (Pre-Restore-Backup fehlgeschlagen — f echo "→ Dienste stoppen…" systemctl --user stop $SERVICES 2>/dev/null || true -if [ -d "$STAGE/mem0" ]; then rm -rf "$MEM0_DIR"; mkdir -p "$MEM0_DIR"; cp -a "$STAGE/mem0/." "$MEM0_DIR/"; fi [ -f "$STAGE/hermes/config.yaml" ] && { mkdir -p "$HERMES"; cp -a "$STAGE/hermes/config.yaml" "$HERMES/"; } [ -f "$STAGE/hermes/.env" ] && cp -a "$STAGE/hermes/.env" "$HERMES/" [ -d "$STAGE/hermes/plugins" ] && { mkdir -p "$HERMES/plugins"; cp -a "$STAGE/hermes/plugins/." "$HERMES/plugins/"; } diff --git a/deploy/stack-postcheck.sh b/deploy/stack-postcheck.sh index 1d675e1..d96e71b 100644 --- a/deploy/stack-postcheck.sh +++ b/deploy/stack-postcheck.sh @@ -3,15 +3,14 @@ # dass der komplette Inferenz-Stack noch FUNKTIONIERT — nicht nur "Befehl lief durch". # Exit 0 = alles ok, sonst 1 → der jobengine-Job wird im UI ROT (state=failed). # -# Pendant zu hermes-postcheck.sh (prüft das Gehirn/Mem0); dieser prüft Router+Engine+MC2 +# Pendant zu hermes-postcheck.sh (prüft das Gehirn); dieser prüft Router+Engine+MC2 # inkl. einer ECHTEN 1-Token-Inferenz (beweist, dass ein Modell wirklich lädt & generiert). set -uo pipefail SWAP_URL="${MC_LLAMA_SWAP_URL:-http://127.0.0.1:8080}" MC_URL="${MC_URL:-http://127.0.0.1:9001}" -MEM0_URL="${MEM0_SERVICE_URL:-http://127.0.0.1:8765}" BRAIN="${MC_WARMUP_MODELS:-fast}"; BRAIN="${BRAIN%% *}" # erstes Modell, falls Liste -EMBED="${MC_WARMUP_EMBED:-embed}"; EMBED="${EMBED%% *}" # Embedding-Modell (Mem0/Gedächtnis) +EMBED="${MC_WARMUP_EMBED:-embed}"; EMBED="${EMBED%% *}" # Embedding-Modell (Gedächtnis) fail=0 echo "=== Stack Post-Update: Funktionsprüfung ===" @@ -48,14 +47,14 @@ else echo "FAIL · Inferenz auf '$BRAIN' fehlgeschlagen (Modell lädt/generiert nicht)"; fail=1 fi -# 3b. Embedding-Modell (für Mem0/Gedächtnis) lädt und liefert einen Vektor? +# 3b. Embedding-Modell (für Gedächtnis) lädt und liefert einen Vektor? eresp="$(curl -s -m 120 -X POST "$SWAP_URL/v1/embeddings" \ -H 'Content-Type: application/json' \ -d "{\"model\":\"$EMBED\",\"input\":\"ping\"}" 2>/dev/null)" if printf '%s' "$eresp" | grep -q '"embedding"'; then echo "PASS · Embedding-Modell '$EMBED' liefert Vektoren" else - echo "FAIL · Embedding-Modell '$EMBED' lädt/antwortet nicht (Mem0/Gedächtnis betroffen)"; fail=1 + echo "FAIL · Embedding-Modell '$EMBED' lädt/antwortet nicht (Gedächtnis betroffen)"; fail=1 fi # 4. MC2 selbst gesund (Engine + Gateway erreichbar)? @@ -85,13 +84,6 @@ if systemctl --user is-enabled --quiet mc2-steward 2>/dev/null; then fi fi -# 5. Mem0-Sidecar (Gedächtnis) erreichbar? -if curl -sf -m 5 "$MEM0_URL/health" >/dev/null 2>&1; then - echo "PASS · Mem0-Sidecar erreichbar" -else - echo "FAIL · Mem0-Sidecar NICHT erreichbar"; fail=1 -fi - if [ "$fail" -eq 0 ]; then echo "=== STACK OK ✓ ===" else diff --git a/deploy/venv-audit.sh b/deploy/venv-audit.sh index da484c6..45f81d6 100644 --- a/deploy/venv-audit.sh +++ b/deploy/venv-audit.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Venv-Audit (monatlich, 17.07.2026): die Python-Nebendienste (Mem0, Voice) altern +# Venv-Audit (monatlich, 17.07.2026): die Python-Nebendienste (Voice) altern # eingefroren — kein Auto-Update-Kanal deckt sie ab. Dieser Audit MELDET nur # (bewusst kein Auto-Update: Python-Deps brechen gern): # je venv: veraltete Pakete zählen (pip list --outdated) + bekannte @@ -9,7 +9,7 @@ # idempotent je Monat). Läuft als Hermes-Cron --no-agent (stdout = Telegram). set -uo pipefail -VENVS=("$HOME/.mem0/venv:mem0" "$HOME/.voice/venv:voice") +VENVS=("$HOME/.voice/venv:voice") AUDIT_VENV="$HOME/.venv-audit" HERMES="$HOME/.local/bin/hermes" MONAT="$(date '+%Y-%m')" diff --git a/deploy/warmup.sh b/deploy/warmup.sh index 85bddf6..4f4bae4 100644 --- a/deploy/warmup.sh +++ b/deploy/warmup.sh @@ -4,7 +4,7 @@ # Eingehängt als ExecStartPost=-/usr/local/bin/llama-swap-warmup.sh in llama-swap. # # `fast` = das Agent-Hirn (Qwen3.6-35B-A3B), `embed` = das Embedding-Modell -# (Qwen3-Embedding-0.6B, für Mem0/Gedächtnis). Die Augen (vision/VL-30B) sind seit 07.07. +# (Qwen3-Embedding-0.6B, für Gedächtnis). Die Augen (vision/VL-30B) sind seit 07.07. # ON-DEMAND (User-Entscheid) und gehören NICHT mehr ins Warm-Set — die Lucy-App wärmt sie # beim Start selbst an. Modelle werden NICHT automatisch zusammen geladen — jedes einzeln # anstoßen, sonst bleibt es kalt. diff --git a/mem0_service/app.py b/mem0_service/app.py deleted file mode 100644 index 22beda9..0000000 --- a/mem0_service/app.py +++ /dev/null @@ -1,483 +0,0 @@ -#!/usr/bin/env python3 -""" -Mem0-Sidecar für Mission Control 2.0 — das auto-lernende, semantische Gedächtnis. - -WARUM ein eigener Dienst? Mem0 + chromadb laufen nur unter Python 3.12 (`~/.mem0/venv`), -das MC2-Backend aber unter Python 3.14 (kann mem0 nicht importieren). Darum kapselt dieser -schlanke FastAPI-Dienst die Mem0-Memory-Instanz und exponiert sie auf localhost. MC2 -(`backend/services/memory.py`) spricht ihn per HTTP an — die `/api/memory`-API-Form bleibt -nach außen unverändert (UI + MCP-Server kompatibel). - -Aufbau: -- Vektor-Store : Chroma embedded (kein Docker auf der Box) — Pfad MEM0_CHROMA_PATH. -- Embeddings : llama.cpp `embed`-Rolle über llama-swap (/v1/embeddings, 1024 Dim). -- LLM-Extraktion: lokales `fast`-Hirn (Qwen3.6) über llama-swap (/v1/chat/completions). - Thinking wird abgeschaltet (NoThinkLLM), sonst bricht die JSON-Extraktion ab. - -Läuft als systemd-User-Dienst (deploy/mem0-service.service) im `~/.mem0/venv`. -Bind: 127.0.0.1 (nur lokal; MC2 proxyt nach außen). -""" - -import logging -import os -import re -from contextlib import asynccontextmanager - -from fastapi import FastAPI, HTTPException -from mem0 import Memory -from mem0.configs.llms.openai import OpenAIConfig -from mem0.llms.openai import OpenAILLM -from pydantic import BaseModel - -log = logging.getLogger("mem0_service") - -# --- Konfiguration (alles über Env überschreibbar; Defaults = Box-Stand) ---------- -USER_ID = os.environ.get("MEM0_USER_ID", "mission-control") # ein geteiltes Gehirn -EMBED_URL = os.environ.get("MEM0_EMBED_URL", "http://127.0.0.1:8080/v1") -LLM_URL = os.environ.get("MEM0_LLM_URL", "http://127.0.0.1:8080/v1") -EMBED_MODEL = os.environ.get("MEM0_EMBED_MODEL", "embed") -LLM_MODEL = os.environ.get("MEM0_LLM_MODEL", "fast") -EMBED_DIMS = int(os.environ.get("MEM0_EMBED_DIMS", "1024")) -CHROMA_PATH = os.environ.get("MEM0_CHROMA_PATH", "/srv/models/mem0/chroma") -HISTORY_DB = os.environ.get("MEM0_HISTORY_DB", "/srv/models/mem0/history.db") -COLLECTION = os.environ.get("MEM0_COLLECTION", "mc2") -API_KEY = os.environ.get("MEM0_API_KEY", "sk-local") # llama.cpp ignoriert den Key -LLM_MAX_TOKENS = int(os.environ.get("MEM0_LLM_MAX_TOKENS", "2048")) -SEARCH_TOP_K = int(os.environ.get("MEM0_SEARCH_TOP_K", "50")) - -# --- Rerank-Zweitstufe (07.07.2026): Die Vektor-Suche findet ÄHNLICHES, der Reranker ordnet -# nach ECHTER Relevanz (Qwen3-Reranker via llama-swap /v1/rerank). Es ändert sich NUR die -# Reihenfolge: `score` bleibt der Vektor-Score (RECALL_MIN_SCORE im Hermes-Plugin bleibt -# kalibriert), Konsumenten, die vorne abschneiden (RECALL_LIMIT), bekommen automatisch die -# relevantesten Fakten. Jeder Fehler → lautlos Vektor-Reihenfolge (Gedächtnis bricht NIE). -RERANK_ENABLED = os.environ.get("MC_RERANK_ENABLED", "1") != "0" -RERANK_URL = os.environ.get("MC_RERANK_URL", "http://127.0.0.1:8080/v1/rerank") -RERANK_MODEL = os.environ.get("MC_RERANK_MODEL", "Qwen3-Reranker-0.6B") -RERANK_TIMEOUT = float(os.environ.get("MC_RERANK_TIMEOUT", "8")) -RERANK_CANDIDATES = int(os.environ.get("MC_RERANK_CANDIDATES", "20")) # nur die Top-N neu ordnen (Latenz) - -# Kategorien = fundierte Memory-Taxonomie (semantisch/prozedural/episodisch), bewusst knapp (4). -# Mem0-OSS kann nicht nativ klassifizieren (Cloud-Feature) → wir lassen das Hirn beim Lernen -# einordnen (classify_facts). Die Beschreibungen steuern die Treffsicherheit der Auto-Zuordnung. -CATEGORIES = ("identity", "knowledge", "rules", "events") -DEFAULT_CATEGORY = "knowledge" -CATEGORY_DESCRIPTIONS = { - "identity": "Identität & Vorlieben des Nutzers: wer er ist (Name, Rolle), wie er angesprochen werden " - "will, persönliche Vorlieben und seine Arbeitsweise.", - "knowledge": "Wissen & Fakten: stabile Fakten über Stack, Projekte, Tools, Infrastruktur und Umgebung.", - "rules": "Regeln & Konventionen: verbindliche Anweisungen und Workflows — wie etwas gemacht werden " - "soll, Do's und Don'ts.", - "events": "Ereignisse & Entscheidungen: was passiert ist, getroffene Entscheidungen, zeitgebundene Vorgänge.", -} - - -class NoThinkLLM(OpenAILLM): - """`fast` (Qwen3.6) ist ein Thinking-Modell. Unter response_format=json_object - verbrennt das Reasoning Tokens und schneidet die JSON-Fakten ab (leeres Ergebnis). - `enable_thinking=false` (chat_template_kwargs) → deterministische, schnelle Extraktion.""" - - def generate_response(self, messages, response_format=None, tools=None, - tool_choice="auto", **kwargs): - eb = dict(kwargs.pop("extra_body", {}) or {}) - eb.setdefault("chat_template_kwargs", {"enable_thinking": False}) - return super().generate_response( - messages, response_format=response_format, tools=tools, - tool_choice=tool_choice, extra_body=eb, **kwargs, - ) - - -def build_memory() -> Memory: - os.environ.setdefault("OPENAI_API_KEY", API_KEY) - os.environ.setdefault("TOKENIZERS_PARALLELISM", "false") - os.makedirs(CHROMA_PATH, exist_ok=True) - os.makedirs(os.path.dirname(HISTORY_DB), exist_ok=True) - cfg = { - "vector_store": {"provider": "chroma", "config": { - "collection_name": COLLECTION, "path": CHROMA_PATH}}, - "embedder": {"provider": "openai", "config": { - "model": EMBED_MODEL, "embedding_dims": EMBED_DIMS, - "openai_base_url": EMBED_URL, "api_key": API_KEY}}, - "llm": {"provider": "openai", "config": { - "model": LLM_MODEL, "openai_base_url": LLM_URL, "api_key": API_KEY, - "temperature": 0.1, "max_tokens": LLM_MAX_TOKENS}}, - "history_db_path": HISTORY_DB, - # Sprache zwingend Deutsch erzwingen — mem0s englischer Extraktions-Prompt zieht sonst - # Richtung Englisch. Eigennamen/Fachbegriffe bleiben unangetastet. - "custom_instructions": ( - "RELEVANZ (ZWINGEND, WICHTIGSTE REGEL): Extrahiere NUR dauerhaft nützliche Fakten ÜBER DEN " - "NUTZER (Identität, Vorlieben, Arbeitsweise), seine Projekte/Entscheidungen/Stände, stabile " - "Fakten über Stack/Tools/Infrastruktur sowie verbindliche Regeln und Workflows. " - "NIEMALS FAKTEN ÜBER DEN ASSISTENTEN SELBST (ZWINGEND): Extrahiere KEINE Aussagen über den " - "Assistenten/die KI (Hermes, Lucy), seine Fähigkeiten, seinen Zustand, seine Tools/MCP-Server, " - "seine Cronjobs, Commit-/Versionsstände oder welche Modelle er nutzt. Das Subjekt eines Fakts " - "ist IMMER der Nutzer oder sein Projekt — niemals 'der Assistent', 'Hermes' oder 'Lucy'. " - "KEINE ZEITGEBUNDENEN ZUSTÄNDE: Ignoriere transiente Momentaufnahmen (Fehler-/Job-Läufe, " - "Commit-Rückstand, Versions-Updates, 'gerade dabei'-/'hochgefahren'-/'aufgewärmt'-Status, " - "Test- und Onboarding-Fragen). IGNORIERE VOLLSTÄNDIG und gib KEINE Fakten zurück bei: " - "Tagesnachrichten und Welt-/Politik-/Börsen-/Promi-Ereignissen, Wetter, allgemeinem Weltwissen " - "ohne Bezug zum Nutzer, vom Assistenten erzeugten Zusammenfassungen oder Recherche-Ergebnissen, " - "Smalltalk, sowie Meta-Aussagen über den Gesprächsverlauf (z.B. 'der Nutzer fragt nach X', " - "'der Nutzer möchte Y prüfen', 'der Assistent tat Z'). Speichere nur, was in Wochen noch " - "nützlich ist; im Zweifel NICHT speichern. " - "SPRACHE (ZWINGEND): Formuliere ALLE Fakten auf DEUTSCH — unabhängig von der " - "Eingabesprache, niemals auf Englisch übersetzen. Eigennamen, Befehle, Pfade, Modell- und " - "Produktnamen sowie etablierte Fachbegriffe (z.B. PowerShell-Push, Deploy, Qwen3.6, Neovim, " - "Vulkan) bleiben unverändert. Schreibe jeden Fakt knapp und atomar — genau eine Aussage." - ), - } - m = Memory.from_config(cfg) - # LLM gegen die Thinking-freie Variante tauschen (gleiche Verbindung). - m.llm = NoThinkLLM(OpenAIConfig( - model=LLM_MODEL, openai_base_url=LLM_URL, api_key=API_KEY, - temperature=0.1, max_tokens=LLM_MAX_TOKENS, - )) - return m - - -_mem: Memory | None = None - - -def mem() -> Memory: - global _mem - if _mem is None: - _mem = build_memory() - return _mem - - -@asynccontextmanager -async def lifespan(_app: FastAPI): - logging.basicConfig(level=logging.INFO) - try: - mem() # Memory beim Start aufbauen (Chroma öffnen, Modelle anpingen) - log.info("Mem0 bereit (chroma=%s, embed=%s, llm=%s)", CHROMA_PATH, EMBED_MODEL, LLM_MODEL) - except Exception: - log.exception("Mem0-Init fehlgeschlagen (Dienst läuft, /health meldet down)") - yield - - -app = FastAPI(title="MC2 Mem0 Sidecar", lifespan=lifespan) - - -# --- Mapping Mem0 <-> MC2-API-Form ----------------------------------------------- -def _to_item(r: dict, score=None) -> dict: - meta = r.get("metadata") or {} - out = { - "id": r.get("id"), - "content": r.get("memory", ""), - "category": meta.get("category") or "stable", - "bereich": meta.get("bereich") or "", - "source": meta.get("source") or "auto", - "created_at": r.get("created_at") or "", - "updated_at": r.get("updated_at") or r.get("created_at") or "", - } - if score is not None: - try: - out["score"] = round(float(score), 4) - except (TypeError, ValueError): - pass - return out - - -class MemIn(BaseModel): - content: str - category: str = DEFAULT_CATEGORY - source: str = "manual" - - -class MemUp(BaseModel): - content: str | None = None - category: str | None = None - - -class LearnIn(BaseModel): - text: str | None = None - messages: list[dict] | None = None - source: str = "auto" - category: str = DEFAULT_CATEGORY - classify: bool = True # Hirn ordnet die gelernten Fakten automatisch ein - - -@app.get("/health") -def health() -> dict: - try: - mem().get_all(filters={"user_id": USER_ID}, top_k=1) - return {"ok": True} - except Exception as exc: - raise HTTPException(503, f"mem0 nicht bereit: {exc}") - - -def _rerank(query: str, items: list[dict]) -> list[dict]: - """Suchtreffer nach echter Relevanz umordnen; bei jedem Fehler unverändert zurück.""" - if not (RERANK_ENABLED and len(items) > 1): - return items - try: - import httpx - r = httpx.post(RERANK_URL, json={ - "model": RERANK_MODEL, - "query": query, - "documents": [i.get("content", "") for i in items], - "top_n": len(items), - }, timeout=RERANK_TIMEOUT) - r.raise_for_status() - results = r.json().get("results") or [] - ranked = [] - for s in sorted(results, key=lambda x: x.get("relevance_score", 0.0), reverse=True): - idx = s.get("index") - if isinstance(idx, int) and 0 <= idx < len(items): - item = dict(items[idx]) - item["rerank_score"] = round(float(s.get("relevance_score", 0.0)), 4) - ranked.append(item) - # Nur übernehmen, wenn der Reranker ALLE Kandidaten bewertet hat (sonst Verlustgefahr). - return ranked if len(ranked) == len(items) else items - except Exception as exc: - log.debug("rerank übersprungen: %s", exc) - return items - - -@app.get("/memory") -def list_memory(q: str = "", category: str = "", bereich: str = "") -> list[dict]: - """q gesetzt → semantische Suche (mit Relevanz-Score). Sonst → alle Fakten. - bereich='alltag'|'projekt' filtert (Fakten OHNE Bereichs-Feld laufen immer mit, - damit Alt-Bestand nie verschwindet).""" - if q: - res = mem().search(q, filters={"user_id": USER_ID}, top_k=SEARCH_TOP_K) - items = [_to_item(r, r.get("score")) for r in res.get("results", [])] - # Zweitstufe: die vorderen Kandidaten nach echter Relevanz ordnen (Rest bleibt hinten dran). - items = _rerank(q, items[:RERANK_CANDIDATES]) + items[RERANK_CANDIDATES:] - else: - res = mem().get_all(filters={"user_id": USER_ID}, top_k=1000) - items = [_to_item(r) for r in res.get("results", [])] - items.sort(key=lambda i: i.get("created_at", ""), reverse=True) - if category: - items = [i for i in items if i["category"] == category] - if bereich in ("alltag", "projekt"): - items = [i for i in items if i.get("bereich") in (bereich, "")] - return items - - -@app.post("/memory", status_code=201) -def add_memory(body: MemIn) -> dict: - """Manueller/agentischer Einzel-Fakt → VERBATIM speichern (infer=False, keine - LLM-Umformung). Auto-Lernen aus Gesprächen läuft über /learn (infer=True).""" - content = body.content.strip() - if not content: - raise HTTPException(400, "content leer") - res = mem().add( - [{"role": "user", "content": content}], - user_id=USER_ID, infer=False, - metadata={"category": body.category, "source": body.source}, - ) - results = res.get("results", []) - if not results: - raise HTTPException(500, "Mem0 hat nichts gespeichert") - new_id = results[0]["id"] - full = mem().get(new_id) or {} - return _to_item(full) if full else _to_item({"id": new_id, "memory": content, - "metadata": {"category": body.category, "source": body.source}}) - - -@app.put("/memory/{mid}") -def update_memory(mid: str, body: MemUp) -> dict: - existing = mem().get(mid) - if not existing: - raise HTTPException(404, "Eintrag nicht gefunden") - cur = _to_item(existing) - new_content = body.content.strip() if body.content is not None else cur["content"] - new_cat = body.category if body.category is not None else cur["category"] - mem().update(mid, data=new_content, - metadata={"category": new_cat, "source": cur["source"]}) - full = mem().get(mid) or {} - return _to_item(full) - - -@app.delete("/memory/{mid}") -def delete_memory(mid: str) -> dict: - if not mem().get(mid): - raise HTTPException(404, "Eintrag nicht gefunden") - mem().delete(mid) - return {"ok": True} - - -@app.get("/graph") -def graph(min_score: float = 0.45, top_k: int = 3) -> dict: - """Fakten als Graph: Knoten = Fakten, Kanten = semantische Ähnlichkeit (Kosinus - der gespeicherten Embeddings, je Knoten die top_k Nachbarn ≥ min_score). Für die - Obsidian-artige Gedächtnis-Visualisierung im UI.""" - items = mem().get_all(filters={"user_id": USER_ID}, top_k=2000).get("results", []) - nodes = [_to_item(r) for r in items] - node_ids = [n["id"] for n in nodes] - idx = {nid: i for i, nid in enumerate(node_ids)} - - # Embeddings direkt aus der Chroma-Collection ziehen (kein Re-Embedding). - col = mem().vector_store.collection - raw = col.get(include=["embeddings"]) - raw_ids = raw.get("ids") or [] - raw_embs = raw.get("embeddings") - edges: list[dict] = [] - vecs = [None] * len(node_ids) - have = 0 - if raw_embs is not None: - for rid, emb in zip(raw_ids, raw_embs): - if rid in idx and emb is not None: - vecs[idx[rid]] = emb - have += 1 - - if have >= 2: - import numpy as np - present = [i for i, v in enumerate(vecs) if v is not None] - M = np.array([vecs[i] for i in present], dtype=float) - norms = np.linalg.norm(M, axis=1, keepdims=True) - norms[norms == 0] = 1.0 - Mn = M / norms - sim = Mn @ Mn.T - seen: set[tuple[int, int]] = set() - for a in range(len(present)): - order = np.argsort(-sim[a]) - cnt = 0 - for b in order: - if b == a: - continue - s = float(sim[a][b]) - if s < min_score: - break - i, j = present[a], present[b] - key = (min(i, j), max(i, j)) - if key not in seen: - seen.add(key) - edges.append({"source": node_ids[i], "target": node_ids[j], - "weight": round(s, 3)}) - cnt += 1 - if cnt >= top_k: - break - return {"nodes": nodes, "edges": edges} - - -def classify_facts(facts: list[tuple[str, str]]) -> dict: - """Ordnet jeden Fakt per LLM einer Kategorie UND einem Bereich zu → - {id: {"category": , "bereich": "alltag"|"projekt"}}. - - Bereich (19.07.2026, Trennung der Welten): "alltag" = der Nutzer als Person - (Vorlieben, Leben, Arbeitsweise, Entscheidungen), "projekt" = Technik-/Code-/ - Infrastruktur-Stände. Grundlage für einen späteren Recall-Filter, damit - Projekt-Schutt nicht in Alltags-Gespräche streut — OHNE zweites Gedächtnis. - Nutzt dasselbe (Thinking-freie) Hirn wie die Extraktion. Fehler = leeres Mapping.""" - if not facts: - return {} - import json as _json - import re as _re - cat_lines = "\n".join(f"- {k}: {v}" for k, v in CATEGORY_DESCRIPTIONS.items()) - system = ( - "Du bist ein präziser Klassifikator für ein Langzeitgedächtnis. Ordne jeden Fakt GENAU EINER " - "Kategorie zu. Verfügbare Kategorien (nur diese Slugs verwenden):\n" + cat_lines + - "\nOrdne zusätzlich jeden Fakt GENAU EINEM Bereich zu: 'alltag' (der Nutzer als Person: " - "Vorlieben, Leben, Anrede, Arbeitsweise, grundsätzliche Entscheidungen) oder 'projekt' " - "(Technik: Code, Modelle, Server, Infrastruktur, Projekt- und Fehlerstände). " - "Antworte NUR als JSON-Objekt der Form " - "{\"\": {\"kategorie\": \"\", \"bereich\": \"alltag|projekt\"}, …}." - ) - body = "\n".join(f"{fid}: {txt}" for fid, txt in facts) - try: - resp = mem().llm.generate_response( - messages=[{"role": "system", "content": system}, - {"role": "user", "content": "Fakten:\n" + body}], - response_format={"type": "json_object"}, - ) - m = _re.search(r"\{.*\}", resp or "", _re.DOTALL) - data = _json.loads(m.group(0)) if m else {} - out: dict = {} - for k, v in data.items(): - if isinstance(v, str): # Alt-Form {"id": "slug"} weiter akzeptieren - if v in CATEGORIES: - out[str(k)] = {"category": v, "bereich": ""} - elif isinstance(v, dict): - cat = v.get("kategorie") or v.get("category") or "" - ber = v.get("bereich") or "" - if cat in CATEGORIES or ber in ("alltag", "projekt"): - out[str(k)] = {"category": cat if cat in CATEGORIES else DEFAULT_CATEGORY, - "bereich": ber if ber in ("alltag", "projekt") else ""} - return out - except Exception: - log.exception("Auto-Klassifikation fehlgeschlagen") - return {} - - -# Deterministischer Guard: mem0-OSS extrahiert trotz custom_instructions hartnäckig Fakten ÜBER -# den Assistenten selbst (Hermes/Lucy) und transiente Zustände (Commit-Rückstand, Cronjob-Läufe, -# Onboarding-/Test-Fragen). Diese Muster werden nach der Extraktion verworfen (der Fakt wird wieder -# gelöscht) — reine Sicherungsschicht, unabhängig vom Extraktions-LLM. -_JUNK_FACT_PATTERNS = [ - r"^\s*(?:der|die|das)\s+assistent", # "Der Assistent ..." (Selbstbeschreibung) - r"\bassistent(?:in)?\s+(?:hermes|lucy)\b", # "... Assistent Hermes/Lucy ..." - r"\bhermes[- ]agent\b", - r"commits?\s+hinter", # Versions-Rückstand (transient) - r"\bcronjob\b", - r"beim\s+letzten\s+(?:lauf|mal)", - r"onboarding[-\s]?frage|\btest-?frage\b", - r"hat\s+den\s+assistenten\s+gebeten", - r"auf\s+betriebstemperatur|hochgefahren", - r"aktualisiert\s+sein\s+ged(?:ä|ae)chtnis", # Selbst-Meta über das Gedächtnis -] -_JUNK_FACT_RE = re.compile("|".join(_JUNK_FACT_PATTERNS), re.IGNORECASE) - - -def _is_junk_fact(text: str) -> bool: - """True, wenn der extrahierte Fakt Assistenten-Meta oder ein transienter Zustand ist.""" - return bool(_JUNK_FACT_RE.search(text or "")) - - -@app.post("/learn") -def learn(body: LearnIn) -> dict: - """Auto-Lernen: Gesprächs-Turns/Text durchreichen → Mem0 EXTRAHIERT Fakten selbst - (infer=True). Anschließend ordnet das Hirn jeden neuen Fakt automatisch einer Kategorie zu.""" - msgs = body.messages - if not msgs and body.text: - msgs = [{"role": "user", "content": body.text}] - if not msgs: - raise HTTPException(400, "text oder messages erforderlich") - res = mem().add( - msgs, user_id=USER_ID, infer=True, - metadata={"category": body.category, "source": body.source}, - ) - results = res.get("results", []) - - # Guard: frisch extrahierte Assistenten-Meta/transiente Fakten sofort wieder verwerfen. - kept = [] - for r in results: - if r.get("event") in ("ADD", "UPDATE") and _is_junk_fact(r.get("memory", "")): - try: - mem().delete(r["id"]) - except Exception: - log.debug("Junk-Fakt-Löschung fehlgeschlagen für %s", r.get("id")) - log.info("mem0 guard: Junk-Fakt verworfen: %s", (r.get("memory") or "")[:80]) - continue - kept.append(r) - results = kept - - cats: dict = {} - if body.classify: - to_classify = [(r["id"], r.get("memory", "")) for r in results - if r.get("id") and r.get("memory") and r.get("event") in ("ADD", "UPDATE")] - cats = classify_facts(to_classify) - for r in results: - info = cats.get(r.get("id")) - if info: - meta = {"category": info["category"], "source": body.source} - if info.get("bereich"): - meta["bereich"] = info["bereich"] - try: - mem().update(r["id"], data=r.get("memory", ""), metadata=meta) - except Exception: - log.debug("Kategorie-Update fehlgeschlagen für %s", r.get("id")) - - return {"results": [ - {"id": r.get("id"), "content": r.get("memory"), "event": r.get("event"), - "category": (cats.get(r.get("id")) or {}).get("category", body.category), - "bereich": (cats.get(r.get("id")) or {}).get("bereich", "")} - for r in results - ]} - - -if __name__ == "__main__": - import uvicorn - - uvicorn.run(app, host="127.0.0.1", port=int(os.environ.get("MEM0_PORT", "8765"))) diff --git a/mem0_service/migrate.py b/mem0_service/migrate.py deleted file mode 100644 index 65b9c57..0000000 --- a/mem0_service/migrate.py +++ /dev/null @@ -1,62 +0,0 @@ -#!/usr/bin/env python3 -""" -Einmalige Migration: alte SQLite-Fakten (`memories`-Tabelle aus dem v1/v2-Memory) -→ Mem0. Läuft im ~/.mem0/venv. Idempotent über eine Marker-Datei. - -Jeder Alt-Eintrag wird VERBATIM übernommen (infer=False), Kategorie/Source als -Metadaten. Beim aktuellen Box-Stand ist die DB leer → No-Op, aber für -Reproduzierbarkeit/künftige Migrationen vorhanden. - -Aufruf: ~/.mem0/venv/bin/python migrate.py [pfad/zur/mc2-memory.db] -""" - -import os -import sqlite3 -import sys - -from app import USER_ID, mem # nutzt dieselbe Mem0-Instanz/Config - -DEFAULT_DB = os.environ.get("MC_MEMORY_DB", "/srv/models/mc2-memory.db") -MARKER = os.environ.get("MEM0_MIGRATION_MARKER", "/srv/models/mem0/.migrated_sqlite") - - -def main() -> int: - db_path = sys.argv[1] if len(sys.argv) > 1 else DEFAULT_DB - if os.path.exists(MARKER): - print(f"Migration bereits erledigt (Marker {MARKER}).") - return 0 - if not os.path.exists(db_path): - print(f"Keine Alt-DB unter {db_path} — nichts zu migrieren.") - os.makedirs(os.path.dirname(MARKER), exist_ok=True) - open(MARKER, "w").close() - return 0 - - conn = sqlite3.connect(db_path) - conn.row_factory = sqlite3.Row - try: - rows = conn.execute( - "SELECT content, category, source FROM memories ORDER BY created_at" - ).fetchall() - except sqlite3.OperationalError: - rows = [] - conn.close() - - m = mem() - n = 0 - for r in rows: - content = (r["content"] or "").strip() - if not content: - continue - m.add([{"role": "user", "content": content}], user_id=USER_ID, infer=False, - metadata={"category": r["category"] or "stable", - "source": r["source"] or "migrated"}) - n += 1 - - os.makedirs(os.path.dirname(MARKER), exist_ok=True) - open(MARKER, "w").close() - print(f"Migriert: {n} Einträge aus {db_path} → Mem0.") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/mem0_service/requirements.txt b/mem0_service/requirements.txt deleted file mode 100644 index d4cc504..0000000 --- a/mem0_service/requirements.txt +++ /dev/null @@ -1,9 +0,0 @@ -# Mem0-Sidecar-Deps im ~/.mem0/venv (Python 3.12). BEWUSST GEPINNT — Upgrades nur -# absichtlich, weil der Sidecar mem0-INTERNA nutzt (NoThink-LLM-Swap, Roh-Zugriff auf die -# Chroma-Collection für den Graphen, Annahmen übers Result-Format). Ein unkontrolliertes -# mem0/chromadb-Upgrade kann das still brechen. -# Upgrade-Prozedur (Backup → Pin anheben → smoke_test.py): siehe docs/UPGRADE.md. -mem0ai==2.0.8 -chromadb==1.5.9 -fastapi==0.138.1 -uvicorn[standard]==0.49.0 diff --git a/mem0_service/smoke_test.py b/mem0_service/smoke_test.py deleted file mode 100644 index c198dd0..0000000 --- a/mem0_service/smoke_test.py +++ /dev/null @@ -1,71 +0,0 @@ -#!/usr/bin/env python3 -""" -Smoke-Test für den Mem0-Sidecar — VOR und NACH jedem mem0/chromadb-Upgrade laufen lassen. -Prüft genau die Pfade, die mem0-INTERNA nutzen (LLM-Extraktion/NoThink, Auto-Einordnung, -Embeddings/Suche, Roh-Chroma-Graph). Nutzt eine WEGWERF-Collection unter /tmp — das echte -Gedächtnis (/srv/models/mem0) wird NICHT angefasst. - -Aufruf: ~/.mem0/venv/bin/python ~/mission-control-v2/mem0_service/smoke_test.py -Exit 0 = alles grün, sonst 1. -""" - -import os -import shutil -import sys -import tempfile -import time - -# WICHTIG: Env vor dem Import von app setzen (app liest die Pfade beim Modul-Load). -_TMP = tempfile.mkdtemp(prefix="mem0_smoke_") -os.environ["MEM0_CHROMA_PATH"] = os.path.join(_TMP, "chroma") -os.environ["MEM0_HISTORY_DB"] = os.path.join(_TMP, "history.db") -os.environ["MEM0_COLLECTION"] = f"smoke_{int(time.time())}" - -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -import app - -_fails: list[str] = [] - - -def check(name: str, cond: bool, detail: str = "") -> None: - print(("PASS" if cond else "FAIL"), "—", name, ("" if cond else f":: {detail}")) - if not cond: - _fails.append(name) - - -def main() -> int: - app.mem() # Memory bauen (öffnet Chroma, pingt Modelle) - - # 1) Verbatim add + Rücklesen - r = app.add_memory(app.MemIn(content="Smoke: der Himmel ist blau.", category="knowledge", source="smoke")) - check("verbatim add + get", bool(r.get("id")) and "blau" in r.get("content", ""), str(r)[:160]) - - # 2) Embeddings / semantische Suche (Bedeutung, nicht Wort) - res = app.list_memory(q="Welche Farbe hat der Himmel?") - check("semantische Suche", any("blau" in i.get("content", "") for i in res), str(res)[:160]) - - # 3) Auto-Lernen: LLM-Extraktion (NoThink) + Deutsch erzwungen - lr = app.learn(app.LearnIn(text="Ich heiße Max und programmiere am liebsten in Go.", source="smoke")) - items = lr.get("results", []) - check("Auto-Lernen extrahiert Fakten", len(items) > 0, str(lr)[:160]) - joined = " ".join((i.get("content") or "") for i in items).lower() - check("Extraktion auf Deutsch", any(w in joined for w in ("heißt", "programmiert", "mag", "bevorzugt", "name", "nutzt")), joined[:160]) - - # 4) Auto-Einordnung in die 4 gültigen Kategorien - cats = {i.get("category") for i in items if i.get("category")} - check("Auto-Einordnung gültige Kategorie", bool(cats) and cats <= set(app.CATEGORIES), str(cats)) - - # 5) Graph aus den Roh-Embeddings (Chroma-Interna) - g = app.graph() - check("Graph liefert Knoten", len(g.get("nodes", [])) >= 1, str({k: len(v) for k, v in g.items()})) - - print("\n" + ("ALLE TESTS GRÜN ✓" if not _fails else f"FEHLGESCHLAGEN: {_fails}")) - return 0 if not _fails else 1 - - -if __name__ == "__main__": - try: - code = main() - finally: - shutil.rmtree(_TMP, ignore_errors=True) - sys.exit(code)