feat(v9): deterministischer Memory-Kurator (Dedupe) statt LLM-Cron

- routers/memory.py: POST /api/memory/dedupe — findet Dubletten (exakt/
  enthalten/ähnlich via SequenceMatcher) je Kategorie, behält den
  vollständigsten Eintrag. Dry-Run (apply:false) + Anwenden. Kein LLM.
- MemoryPanel: "🧹 Aufräumen"-Button (Vorschau → Bestätigung → löschen).

Grund: LLM-Cron-Kurator hat Edits nur halluziniert/als Text ausgegeben
statt auszuführen (Hermes 4 + Qwen). Deterministisch = verlässlich.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-06-24 17:49:18 +02:00
parent 0c7f3104d4
commit dd2938ee53
3 changed files with 123 additions and 34 deletions
+66
View File
@@ -6,9 +6,11 @@ SQLite aus stdlib — kein Vektor-Overhead, keine neue Abhaengigkeit.
Alle MCP-Tools teilen denselben Speicher via mcp_memory.py-Wrapper.
"""
import re
import sqlite3
import uuid
from datetime import datetime, timezone
from difflib import SequenceMatcher
from enum import Enum
from typing import Optional
@@ -98,6 +100,70 @@ def export_memories():
return {"text": "\n".join(lines), "count": len(rows)}
def _norm(s: str) -> str:
"""Normalisiert Text fuer Dubletten-Vergleich: klein, ohne Satzzeichen, Whitespace kollabiert."""
s = re.sub(r"[^\w\s]", " ", s.lower(), flags=re.UNICODE)
return re.sub(r"\s+", " ", s).strip()
class _DedupeIn(BaseModel):
apply: bool = False # False = Dry-Run (nur Vorschau), True = wirklich loeschen
threshold: float = 0.85 # Aehnlichkeits-Schwelle (SequenceMatcher)
@router.post("/memory/dedupe")
def dedupe_memories(body: _DedupeIn):
"""Deterministischer Kurator: findet Dubletten (exakt / enthalten / aehnlich) je Kategorie,
behaelt den vollstaendigsten (laengsten) Eintrag, entfernt die uebrigen. KEIN LLM.
Konservativ: nur innerhalb derselben Kategorie, Containment braucht vollstaendige Teilstring-Deckung."""
db = _db()
# laengste zuerst -> der vollstaendigste Eintrag einer Gruppe wird zum Kanon
rows = [dict(r) for r in db.execute(
"SELECT * FROM memories ORDER BY length(content) DESC, created_at ASC"
).fetchall()]
used: set[str] = set()
groups: list[dict] = []
for i, a in enumerate(rows):
if a["id"] in used:
continue
na = _norm(a["content"])
if not na:
continue
dups = []
for b in rows[i + 1:]:
if b["id"] in used or b["category"] != a["category"]:
continue
nb = _norm(b["content"])
if not nb:
continue
contained = nb in na or na in nb
ratio = SequenceMatcher(None, na, nb).ratio()
if contained or ratio >= body.threshold:
dups.append(b)
used.add(b["id"])
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],
})
dup_count = sum(len(g["remove"]) for g in groups)
removed = 0
if body.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()
return {
"groups": groups,
"duplicate_count": dup_count,
"removed": removed,
"applied": body.apply,
}
@router.get("/memory")
def list_memories(q: str = "", category: str = ""):
db = _db()