Feat: Fundierte 4-Typen-Memory-Taxonomie + LLM-Auto-Einordnung

Saubere Neuordnung der Gedaechtnis-Kategorien an der etablierten Memory-Taxonomie
(semantisch/prozedural/episodisch), bewusst knapp (Best Practice: 3-5, klar beschrieben):
  identity (Identitaet & Vorlieben) · knowledge (Wissen & Fakten) ·
  rules (Regeln & Konventionen) · events (Ereignisse & Entscheidungen)
Loest die alten gemischten 5 (user/instruction/stable/versioned/ephemeral) ab.

Auto-Einordnung: OSS-mem0 kann nicht nativ kategorisieren (Cloud-Feature) -> nach der
Fakt-Extraktion ordnet dasselbe (Thinking-freie) Hirn jeden neuen Fakt per JSON-Call
genau einer Kategorie zu (classify_facts im Sidecar /learn). Behebt den "alles ist stable"-
Bug. Manuelle Eintraege: Kategorie weiter waehlbar (Default knowledge).

Umgesetzt in mem0_service, backend (services/routers), mcp_memory (Tool-Docs) und Frontend
(MemoryView + GraphView: Labels/Farben/Filter). Kein Migrationsbedarf (leerer Start).

Live verifiziert: gemischter Absatz -> identity/rules/knowledge/events korrekt zugeordnet.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-06-27 21:04:55 +02:00
parent 689ea48d72
commit b383711f6d
10 changed files with 128 additions and 78 deletions
+2 -2
View File
@@ -10,7 +10,7 @@ router = APIRouter(prefix="/api")
class MemIn(BaseModel):
content: str
category: str = "stable"
category: str = "knowledge"
source: str = "manual"
@@ -28,7 +28,7 @@ class LearnIn(BaseModel):
text: str | None = None
messages: list[dict] | None = None
source: str = "auto"
category: str = "stable"
category: str = "knowledge"
@router.get("/memory/export")
+1 -1
View File
@@ -19,7 +19,7 @@ import httpx
from config import MEM0_SERVICE_URL
CATEGORIES = ("user", "instruction", "stable", "versioned", "ephemeral")
CATEGORIES = ("identity", "knowledge", "rules", "events")
_TIMEOUT = httpx.Timeout(60.0, connect=5.0) # LLM-Extraktion kann ein paar Sekunden dauern
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -7,8 +7,8 @@
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>Mission Control 2.0</title>
<script type="module" crossorigin src="/assets/index-BVXzL-Al.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DGdkjhdp.css">
<script type="module" crossorigin src="/assets/index-BbiMdr0-.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-De4PPCGb.css">
</head>
<body>
<div id="root"></div>
+2 -2
View File
@@ -4,10 +4,10 @@ import { Trash2, Sparkles, Share2 } from "lucide-react"
import { type MemoryGraph } from "@/lib/api"
const CAT_COLOR: Record<string, string> = {
user: "#22d3ee", instruction: "#a78bfa", stable: "#6366f1", versioned: "#fbbf24", ephemeral: "#f472b6",
identity: "#22d3ee", knowledge: "#6366f1", rules: "#a78bfa", events: "#fbbf24",
}
const CAT_LABEL: Record<string, string> = {
user: "User", instruction: "Regel", stable: "Fakt", versioned: "Version", ephemeral: "Temporär",
identity: "Identität", knowledge: "Wissen", rules: "Regeln", events: "Ereignisse",
}
const AUTO = new Set(["auto", "agent", "hermes"])
+9 -10
View File
@@ -1,6 +1,6 @@
import { useState, useMemo, lazy, Suspense } from "react"
import {
Trash2, Sparkles, User, Scroll, Shield, Tag, Clock, Search, Plus, BookOpen,
Trash2, Sparkles, User, Scroll, Shield, Clock, Search, Plus, BookOpen,
Share2, List, MessagesSquare, X, Copy, Check, Terminal as TerminalIcon, Send,
} from "lucide-react"
import { api, type DedupeResult } from "@/lib/api"
@@ -12,23 +12,22 @@ import { GraphErrorBoundary } from "./GraphErrorBoundary"
// reagraph + three.js sind schwer → erst laden, wenn der Graph-Tab geöffnet wird.
const GraphView = lazy(() => import("./GraphView").then((m) => ({ default: m.GraphView })))
const CATEGORIES = ["user", "instruction", "stable", "versioned", "ephemeral"]
const CATEGORIES = ["identity", "knowledge", "rules", "events"]
// Herkunft: automatisch gelernt (Mem0-Extraktion / Agent / Hermes) vs. manuell/Vorlage.
const AUTO_SOURCES = new Set(["auto", "agent", "hermes"])
const CAT_CONFIG: Record<string, { label: string; icon: any; bg: string; text: string }> = {
user: { label: "User", icon: User, bg: "bg-cyan-500/10", text: "text-cyan-400" },
instruction: { label: "Regel", icon: Scroll, bg: "bg-violet-500/10", text: "text-violet-400" },
stable: { label: "Fakt", icon: Shield, bg: "bg-indigo-500/10", text: "text-indigo-400" },
versioned: { label: "Version", icon: Tag, bg: "bg-amber-500/10", text: "text-amber-400" },
ephemeral: { label: "Temporär", icon: Clock, bg: "bg-pink-500/10", text: "text-pink-400" },
identity: { label: "Identität", icon: User, bg: "bg-cyan-500/10", text: "text-cyan-400" },
knowledge: { label: "Wissen", icon: Shield, bg: "bg-indigo-500/10", text: "text-indigo-400" },
rules: { label: "Regeln", icon: Scroll, bg: "bg-violet-500/10", text: "text-violet-400" },
events: { label: "Ereignisse", icon: Clock, bg: "bg-amber-500/10", text: "text-amber-400" },
}
const DEFAULT_CAT = { label: "Gedächtnis", icon: BookOpen, bg: "bg-muted/10", text: "text-muted-foreground" }
const BORDER_CLASSES: Record<string, string> = {
user: "border-l-cyan-500/80", instruction: "border-l-violet-500/80", stable: "border-l-indigo-500/80",
versioned: "border-l-amber-500/80", ephemeral: "border-l-pink-500/80",
identity: "border-l-cyan-500/80", knowledge: "border-l-indigo-500/80",
rules: "border-l-violet-500/80", events: "border-l-amber-500/80",
}
// Onboarding-Prompt: der Nutzer startet damit ein Gespräch mit Hermes (Terminal/Telegram).
@@ -46,7 +45,7 @@ export function MemoryView() {
const [filter, setFilter] = useState("")
const [q, setQ] = useState("")
const [content, setContent] = useState("")
const [category, setCategory] = useState("stable")
const [category, setCategory] = useState("knowledge")
const [deduping, setDeduping] = useState(false)
const [copied, setCopied] = useState(false)
const [view, setView] = useState<"liste" | "graph">("graph")
+4 -4
View File
@@ -55,11 +55,11 @@ def _delete(path: str):
def get_memories(category: str = "") -> str:
"""Lädt gespeicherte Fakten/Entscheidungen aus dem geteilten Gedächtnis.
Nutze dies EINMAL am Session-Beginn, wenn du Projekt-Kontext brauchst — nicht wiederholt.
category: user | instruction | stable | versioned | ephemeral | (leer = alle)"""
category: identity | knowledge | rules | events | (leer = alle)"""
items = _get("/api/memory", **({"category": category} if category else {}))
if not items:
return "Keine Memories gespeichert."
icon = {"stable": "🔵", "versioned": "🟡", "ephemeral": "", "user": "👤", "instruction": "📋"}
icon = {"identity": "👤", "knowledge": "🔵", "rules": "📋", "events": "🕒"}
return "\n".join(
f"{icon.get(m['category'], '·')} [{m['category']}] {m['content']} (ID: {m['id'][:8]})"
for m in items
@@ -81,12 +81,12 @@ def search_memories(q: str) -> str:
@mcp.tool()
def add_memory(content: str, category: str = "stable", source: str = "agent") -> str:
def add_memory(content: str, category: str = "knowledge", source: str = "agent") -> str:
"""Speichert EINEN dauerhaften Fakt im geteilten Gedächtnis (alle Tools sehen ihn).
Nur aufrufen, WENN gerade etwas Dauerhaftes entstanden ist (Konvention, Architektur-
Entscheidung, Tech-Version, Nutzer-Präferenz) UND es noch nicht existiert (vorher
search_memories!). Knapp & atomar. Existiert ein passender Eintrag → update_memory.
category: user | instruction | stable | versioned | ephemeral"""
category: identity (Nutzer/Vorlieben) | knowledge (Fakten/Stack) | rules (Regeln/Konventionen) | events (Ereignisse/Entscheidungen)"""
m = _post("/api/memory", {"content": content, "category": category, "source": source})
return f"Gespeichert (ID: {m['id'][:8]}): {content}"
+64 -8
View File
@@ -45,9 +45,19 @@ API_KEY = os.environ.get("MEM0_API_KEY", "sk-local") # llama.cpp ignoriert den
LLM_MAX_TOKENS = int(os.environ.get("MEM0_LLM_MAX_TOKENS", "2048"))
SEARCH_TOP_K = int(os.environ.get("MEM0_SEARCH_TOP_K", "50"))
# 5 Kategorien wie im alten System (UI-Kompatibilität). Mem0 selbst kennt keine
# Kategorien — wir führen sie als Metadaten mit.
CATEGORIES = ("user", "instruction", "stable", "versioned", "ephemeral")
# 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):
@@ -140,7 +150,7 @@ def _to_item(r: dict, score=None) -> dict:
class MemIn(BaseModel):
content: str
category: str = "stable"
category: str = DEFAULT_CATEGORY
source: str = "manual"
@@ -153,7 +163,8 @@ class LearnIn(BaseModel):
text: str | None = None
messages: list[dict] | None = None
source: str = "auto"
category: str = "stable"
category: str = DEFAULT_CATEGORY
classify: bool = True # Hirn ordnet die gelernten Fakten automatisch ein
@app.get("/health")
@@ -277,10 +288,38 @@ def graph(min_score: float = 0.45, top_k: int = 3) -> dict:
return {"nodes": nodes, "edges": edges}
def classify_facts(facts: list[tuple[str, str]]) -> dict:
"""Ordnet jeden Fakt per LLM GENAU einer Kategorie zu → {id: category}.
Nutzt dasselbe (Thinking-freie) Hirn wie die Extraktion. Fehler = leeres Mapping (Fallback)."""
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 +
"\nAntworte NUR als JSON-Objekt der Form {\"<id>\": \"<slug>\", …}."
)
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.S)
data = _json.loads(m.group(0)) if m else {}
return {str(k): v for k, v in data.items() if v in CATEGORIES}
except Exception:
log.exception("Auto-Klassifikation fehlgeschlagen")
return {}
@app.post("/learn")
def learn(body: LearnIn) -> dict:
"""Auto-Lernen: Gesprächs-Turns/Text durchreichen → Mem0 EXTRAHIERT Fakten selbst
(infer=True) und entscheidet ADD/UPDATE/NONE gegen das bestehende Gedächtnis."""
(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}]
@@ -290,9 +329,26 @@ def learn(body: LearnIn) -> dict:
msgs, user_id=USER_ID, infer=True,
metadata={"category": body.category, "source": body.source},
)
results = res.get("results", [])
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:
cat = cats.get(r.get("id"))
if cat:
try:
mem().update(r["id"], data=r.get("memory", ""),
metadata={"category": cat, "source": body.source})
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")}
for r in res.get("results", [])
{"id": r.get("id"), "content": r.get("memory"), "event": r.get("event"),
"category": cats.get(r.get("id"), body.category)}
for r in results
]}