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): class MemIn(BaseModel):
content: str content: str
category: str = "stable" category: str = "knowledge"
source: str = "manual" source: str = "manual"
@@ -28,7 +28,7 @@ class LearnIn(BaseModel):
text: str | None = None text: str | None = None
messages: list[dict] | None = None messages: list[dict] | None = None
source: str = "auto" source: str = "auto"
category: str = "stable" category: str = "knowledge"
@router.get("/memory/export") @router.get("/memory/export")
+1 -1
View File
@@ -19,7 +19,7 @@ import httpx
from config import MEM0_SERVICE_URL 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 _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="manifest" href="/manifest.webmanifest" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>Mission Control 2.0</title> <title>Mission Control 2.0</title>
<script type="module" crossorigin src="/assets/index-BVXzL-Al.js"></script> <script type="module" crossorigin src="/assets/index-BbiMdr0-.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DGdkjhdp.css"> <link rel="stylesheet" crossorigin href="/assets/index-De4PPCGb.css">
</head> </head>
<body> <body>
<div id="root"></div> <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" import { type MemoryGraph } from "@/lib/api"
const CAT_COLOR: Record<string, string> = { 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> = { 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"]) const AUTO = new Set(["auto", "agent", "hermes"])
+9 -10
View File
@@ -1,6 +1,6 @@
import { useState, useMemo, lazy, Suspense } from "react" import { useState, useMemo, lazy, Suspense } from "react"
import { 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, Share2, List, MessagesSquare, X, Copy, Check, Terminal as TerminalIcon, Send,
} from "lucide-react" } from "lucide-react"
import { api, type DedupeResult } from "@/lib/api" 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. // 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 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. // Herkunft: automatisch gelernt (Mem0-Extraktion / Agent / Hermes) vs. manuell/Vorlage.
const AUTO_SOURCES = new Set(["auto", "agent", "hermes"]) const AUTO_SOURCES = new Set(["auto", "agent", "hermes"])
const CAT_CONFIG: Record<string, { label: string; icon: any; bg: string; text: string }> = { 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" }, identity: { label: "Identität", 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" }, knowledge: { label: "Wissen", icon: Shield, bg: "bg-indigo-500/10", text: "text-indigo-400" },
stable: { label: "Fakt", 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" },
versioned: { label: "Version", icon: Tag, bg: "bg-amber-500/10", text: "text-amber-400" }, events: { label: "Ereignisse", icon: Clock, bg: "bg-amber-500/10", text: "text-amber-400" },
ephemeral: { label: "Temporär", icon: Clock, bg: "bg-pink-500/10", text: "text-pink-400" },
} }
const DEFAULT_CAT = { label: "Gedächtnis", icon: BookOpen, bg: "bg-muted/10", text: "text-muted-foreground" } const DEFAULT_CAT = { label: "Gedächtnis", icon: BookOpen, bg: "bg-muted/10", text: "text-muted-foreground" }
const BORDER_CLASSES: Record<string, string> = { const BORDER_CLASSES: Record<string, string> = {
user: "border-l-cyan-500/80", instruction: "border-l-violet-500/80", stable: "border-l-indigo-500/80", identity: "border-l-cyan-500/80", knowledge: "border-l-indigo-500/80",
versioned: "border-l-amber-500/80", ephemeral: "border-l-pink-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). // 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 [filter, setFilter] = useState("")
const [q, setQ] = useState("") const [q, setQ] = useState("")
const [content, setContent] = useState("") const [content, setContent] = useState("")
const [category, setCategory] = useState("stable") const [category, setCategory] = useState("knowledge")
const [deduping, setDeduping] = useState(false) const [deduping, setDeduping] = useState(false)
const [copied, setCopied] = useState(false) const [copied, setCopied] = useState(false)
const [view, setView] = useState<"liste" | "graph">("graph") 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: def get_memories(category: str = "") -> str:
"""Lädt gespeicherte Fakten/Entscheidungen aus dem geteilten Gedächtnis. """Lädt gespeicherte Fakten/Entscheidungen aus dem geteilten Gedächtnis.
Nutze dies EINMAL am Session-Beginn, wenn du Projekt-Kontext brauchst — nicht wiederholt. 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 {})) items = _get("/api/memory", **({"category": category} if category else {}))
if not items: if not items:
return "Keine Memories gespeichert." return "Keine Memories gespeichert."
icon = {"stable": "🔵", "versioned": "🟡", "ephemeral": "", "user": "👤", "instruction": "📋"} icon = {"identity": "👤", "knowledge": "🔵", "rules": "📋", "events": "🕒"}
return "\n".join( return "\n".join(
f"{icon.get(m['category'], '·')} [{m['category']}] {m['content']} (ID: {m['id'][:8]})" f"{icon.get(m['category'], '·')} [{m['category']}] {m['content']} (ID: {m['id'][:8]})"
for m in items for m in items
@@ -81,12 +81,12 @@ def search_memories(q: str) -> str:
@mcp.tool() @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). """Speichert EINEN dauerhaften Fakt im geteilten Gedächtnis (alle Tools sehen ihn).
Nur aufrufen, WENN gerade etwas Dauerhaftes entstanden ist (Konvention, Architektur- Nur aufrufen, WENN gerade etwas Dauerhaftes entstanden ist (Konvention, Architektur-
Entscheidung, Tech-Version, Nutzer-Präferenz) UND es noch nicht existiert (vorher Entscheidung, Tech-Version, Nutzer-Präferenz) UND es noch nicht existiert (vorher
search_memories!). Knapp & atomar. Existiert ein passender Eintrag → update_memory. 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}) m = _post("/api/memory", {"content": content, "category": category, "source": source})
return f"Gespeichert (ID: {m['id'][:8]}): {content}" 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")) LLM_MAX_TOKENS = int(os.environ.get("MEM0_LLM_MAX_TOKENS", "2048"))
SEARCH_TOP_K = int(os.environ.get("MEM0_SEARCH_TOP_K", "50")) 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 = fundierte Memory-Taxonomie (semantisch/prozedural/episodisch), bewusst knapp (4).
# Kategorien — wir führen sie als Metadaten mit. # Mem0-OSS kann nicht nativ klassifizieren (Cloud-Feature) → wir lassen das Hirn beim Lernen
CATEGORIES = ("user", "instruction", "stable", "versioned", "ephemeral") # 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): class NoThinkLLM(OpenAILLM):
@@ -140,7 +150,7 @@ def _to_item(r: dict, score=None) -> dict:
class MemIn(BaseModel): class MemIn(BaseModel):
content: str content: str
category: str = "stable" category: str = DEFAULT_CATEGORY
source: str = "manual" source: str = "manual"
@@ -153,7 +163,8 @@ class LearnIn(BaseModel):
text: str | None = None text: str | None = None
messages: list[dict] | None = None messages: list[dict] | None = None
source: str = "auto" source: str = "auto"
category: str = "stable" category: str = DEFAULT_CATEGORY
classify: bool = True # Hirn ordnet die gelernten Fakten automatisch ein
@app.get("/health") @app.get("/health")
@@ -277,10 +288,38 @@ def graph(min_score: float = 0.45, top_k: int = 3) -> dict:
return {"nodes": nodes, "edges": edges} 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") @app.post("/learn")
def learn(body: LearnIn) -> dict: def learn(body: LearnIn) -> dict:
"""Auto-Lernen: Gesprächs-Turns/Text durchreichen → Mem0 EXTRAHIERT Fakten selbst """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 msgs = body.messages
if not msgs and body.text: if not msgs and body.text:
msgs = [{"role": "user", "content": body.text}] msgs = [{"role": "user", "content": body.text}]
@@ -290,9 +329,26 @@ def learn(body: LearnIn) -> dict:
msgs, user_id=USER_ID, infer=True, msgs, user_id=USER_ID, infer=True,
metadata={"category": body.category, "source": body.source}, 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": [ return {"results": [
{"id": r.get("id"), "content": r.get("memory"), "event": r.get("event")} {"id": r.get("id"), "content": r.get("memory"), "event": r.get("event"),
for r in res.get("results", []) "category": cats.get(r.get("id"), body.category)}
for r in results
]} ]}