From 7ae6ac0e4c2388ba4f8a347adac08f6317d52b17 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 15 Mar 2026 17:34:15 +0700 Subject: [PATCH] RAG v2: Klassifizierung (6 Typen, 3 Confidence), source_type, Auto-Supersede, /memory erweitert --- homelab-ai-bot/context.py | 79 +++++++++++++++------------------ homelab-ai-bot/llm.py | 51 +++++++++++---------- homelab-ai-bot/memory_client.py | 40 ++++++++++------- homelab-ai-bot/telegram_bot.py | 32 ++++++++----- 4 files changed, 108 insertions(+), 94 deletions(-) diff --git a/homelab-ai-bot/context.py b/homelab-ai-bot/context.py index 32b037e7..b5eddffd 100644 --- a/homelab-ai-bot/context.py +++ b/homelab-ai-bot/context.py @@ -297,20 +297,34 @@ def _tool_memory_read(scope=""): import logging as _logging _log = _logging.getLogger("context") -last_suggest_result = {"type": None, "candidate_id": None} +last_suggest_result = {"type": None} + +VALID_MEMORY_TYPES = {"fact", "preference", "relationship", "plan", "temporary", "uncertain"} +VALID_CONFIDENCE = {"high", "medium", "low"} +NEEDS_EXPIRY = {"plan", "temporary"} + +_current_source_type = "telegram_text" -def _tool_memory_suggest(scope, kind, content, memory_type="temporary", expires_at=None): +def set_source_type(st: str): + global _current_source_type + _current_source_type = st + + +def _tool_memory_suggest(scope, kind, content, memory_type="fact", confidence="high", expires_at=None): import memory_client from datetime import datetime global last_suggest_result - _log.info("memory_suggest aufgerufen: scope=%s kind=%s type=%s content=%s", scope, kind, memory_type, content[:80]) - if memory_type not in ("temporary", "permanent"): - memory_type = "temporary" + if memory_type not in VALID_MEMORY_TYPES: + memory_type = "fact" + if confidence not in VALID_CONFIDENCE: + confidence = "high" + + _log.info("memory_suggest: type=%s conf=%s src=%s content=%s", memory_type, confidence, _current_source_type, content[:80]) exp_epoch = None - if memory_type == "temporary": + if memory_type in NEEDS_EXPIRY: if expires_at: exp_epoch = memory_client.parse_expires_from_text(expires_at) if not exp_epoch: @@ -323,53 +337,34 @@ def _tool_memory_suggest(scope, kind, content, memory_type="temporary", expires_ "kind": kind, "content": content, "source": "bot-suggest", - "status": "candidate", + "status": "active", + "confidence": confidence, "memory_type": memory_type, + "source_type": _current_source_type, } if exp_epoch: data["expires_at"] = exp_epoch result = memory_client._post("/memory", data) - prev_type = last_suggest_result.get("type") if result and result.get("duplicate"): - ex_status = result.get("existing_status", "?") ex_type = result.get("existing_memory_type", "") ex_exp = result.get("existing_expires_at") - ex_id = result.get("existing_id") - - if ex_status == "candidate": - if prev_type != "new_candidate": - last_suggest_result = {"type": "existing_candidate", "candidate_id": ex_id} - _log.info("Duplikat: bestehender Kandidat ID=%s (prev=%s)", ex_id, prev_type) - return "Noch nicht bestaetigt — zeige Auswahl erneut." - elif ex_status == "active": - if ex_type == "temporary" and ex_exp: - exp_str = datetime.fromtimestamp(ex_exp).strftime("%d.%m.%Y") - if prev_type != "new_candidate": - last_suggest_result = {"type": "active_temporary", "candidate_id": None} - return f"Schon temporaer gespeichert bis {exp_str}." - elif ex_type == "permanent": - if prev_type != "new_candidate": - last_suggest_result = {"type": "active_permanent", "candidate_id": None} - return "Schon dauerhaft gespeichert." - else: - if prev_type != "new_candidate": - last_suggest_result = {"type": "active_other", "candidate_id": None} - return "Bereits aktiv gespeichert." - elif ex_status == "archived": - if prev_type != "new_candidate": - last_suggest_result = {"type": "existing_candidate", "candidate_id": ex_id} - memory_client._patch(f"/memory/{ex_id}", {"status": "candidate"}) - return "War archiviert — erneut als Kandidat vorgeschlagen." - else: - return "Bereits vorhanden." + last_suggest_result = {"type": "duplicate"} + if ex_type in NEEDS_EXPIRY and ex_exp: + return f"Weiss ich schon (bis {datetime.fromtimestamp(ex_exp).strftime('%d.%m.%Y')})." + return "Weiss ich schon." if result and result.get("ok"): - last_suggest_result = {"type": "new_candidate", "candidate_id": None} - type_label = "temporaer" if memory_type == "temporary" else "dauerhaft" - return f"Vorschlag gespeichert als Kandidat ({type_label})." - return "Konnte Vorschlag nicht speichern." + sup = result.get("superseded_id") + last_suggest_result = {"type": "saved", "item_id": result.get("id"), "superseded": sup} + msg = f"Gemerkt ({memory_type}, {confidence})." + if sup: + msg += f" Alten Eintrag #{sup} ersetzt." + _log.info("Superseded: #%s -> #%s", sup, result.get("id")) + _log.info("Gespeichert: ID=%s type=%s conf=%s", result.get("id"), memory_type, confidence) + return msg + return "Konnte nicht speichern." def _tool_session_search(query): @@ -414,7 +409,7 @@ def get_tool_handlers(session_id: str = None) -> dict: "get_todays_mails": lambda: _tool_get_todays_mails(), "get_smart_mail_digest": lambda hours=24: _tool_get_smart_mail_digest(hours=hours), "memory_read": lambda scope="": _tool_memory_read(scope), - "memory_suggest": lambda scope, kind, content, memory_type="temporary", expires_at=None: _tool_memory_suggest(scope, kind, content, memory_type=memory_type, expires_at=expires_at), + "memory_suggest": lambda scope, kind, content, memory_type="fact", confidence="high", expires_at=None: _tool_memory_suggest(scope, kind, content, memory_type=memory_type, confidence=confidence, expires_at=expires_at), "session_search": lambda query: _tool_session_search(query), "session_summary": lambda topic="": _tool_session_summary(session_id, topic=topic) if session_id else "Keine Session aktiv.", } diff --git a/homelab-ai-bot/llm.py b/homelab-ai-bot/llm.py index 3a450d20..8518c676 100644 --- a/homelab-ai-bot/llm.py +++ b/homelab-ai-bot/llm.py @@ -30,26 +30,28 @@ WICHTIG: Beantworte Fragen IMMER anhand dieser Fakten! Wenn der User z.B. fragt Das Gedaechtnis ist deine primaere Wissensquelle ueber den User. GEDAECHTNIS — memory_suggest: -Du MUSST memory_suggest aufrufen wenn der User etwas sagt das spaeter nuetzlich ist. -Dabei IMMER memory_type angeben: +Du MUSST memory_suggest aufrufen wenn der User etwas Nuetzliches sagt. +memory_suggest speichert DIREKT. Duplikate werden automatisch erkannt. Widersprueche werden automatisch aufgeloest (altes Item wird ersetzt). -TEMPORARY (memory_type="temporary") — hat ein Ablaufdatum: -- Reiseplaene ("fliege nach...", "bin naechste Woche in...") -- Termine ("morgen 14 Uhr Zahnarzt", "am Freitag Meeting") -- Kurzfristige Aufenthalte ("bin bis Mittwoch in Berlin") -- Einmalige Vorhaben ("will diese Woche den Server migrieren") -Bei temporary: expires_at mit dem relevanten Zeitausdruck angeben (z.B. "naechste Woche", "morgen", "am Freitag"). +MEMORY_TYPE — immer angeben: +- "preference" → Vorlieben ("Lieblingskaffee ist Flat White", "bevorzuge dunkles Theme") +- "relationship" → Beziehungen/Rollen ("Ali ist Ansprechpartner fuer Wohnung") +- "fact" → Stabile Fakten ("Server IP geaendert", "Jarvis aktiv seit...") +- "plan" → Konkrete Vorhaben mit Zeitbezug ("Reise nach Frankfurt am 18.03.") +- "temporary" → Kurzfristige Zustaende ("bin bis Mittwoch in Berlin", "morgen Zahnarzt") +- "uncertain" → Vage Aussagen ("vielleicht fliege ich frueher", "glaube Ali kuemmert sich") -PERMANENT (memory_type="permanent") — bleibt dauerhaft: -- Persoenliche Praeferenzen ("ich bevorzuge...", "nenn mich...") -- Rollen/Beziehungen ("Ali ist mein Ansprechpartner fuer...") -- Stabile Infrastruktur-Fakten ("neuer Server heisst...", "IP geaendert auf...") -- Projektstatus ("Jarvis ist jetzt aktiv") -- Wiederkehrende Regeln ("API-Kosten monatlich beobachten") +CONFIDENCE — immer angeben: +- "high" → Klare Aussage ("Mein Lieblingskaffee ist Flat White") +- "medium" → Wahrscheinlich aber nicht 100% ("Ali kuemmert sich wohl darum") +- "low" → Sehr vage ("vielleicht aendere ich das noch") -Im Zweifel: lieber temporary als permanent. -memory_suggest speichert DIREKT — kein Bestaetigen noetig. Das System erkennt Duplikate automatisch. Bei Duplikaten sag kurz "Weiss ich schon." und beantworte die Frage. -NICHT speichern: Passwoerter, Tokens, Smalltalk, Hoeflichkeiten, reine Fragen. +REGELN: +- Bei plan/temporary: IMMER expires_at mit Zeitausdruck angeben ("naechste Woche", "morgen") +- Bei uncertain mit low confidence: NUR speichern wenn es trotzdem nuetzlich sein koennte +- Vages Gerede, Smalltalk, emotionale Kurzaeusserungen: NICHT speichern +- Passwoerter, Tokens: NIE speichern +- Bei Widerspruch zu bestehendem Wissen: trotzdem speichern, System erkennt und ersetzt automatisch SESSION-RUECKBLICK: - "Was haben wir besprochen?" → session_summary OHNE topic @@ -303,17 +305,18 @@ TOOLS = [ "type": "function", "function": { "name": "memory_suggest", - "description": "Speichert einen neuen Fakt als Kandidat. IMMER aufrufen wenn der User Reiseplaene, zeitliche Vorhaben, Projektstatus, Vorlieben oder stabile Fakten mitteilt. IMMER memory_type angeben: 'temporary' fuer zeitlich begrenzte Dinge (Reisen, Termine, einmalige Vorhaben), 'permanent' fuer stabile Fakten (Praeferenzen, Rollen, Regeln, Infrastruktur). Im Zweifel temporary.", + "description": "Speichert einen Fakt direkt im Langzeitgedaechtnis. IMMER aufrufen wenn der User Reiseplaene, Termine, Vorlieben, Beziehungen, Projektstatus oder stabile Fakten mitteilt. System erkennt Duplikate und ersetzt Widersprueche automatisch.", "parameters": { "type": "object", "properties": { "scope": {"type": "string", "enum": ["user", "environment", "project"], "description": "user=persoenlich, environment=Infrastruktur, project=Projekt"}, - "kind": {"type": "string", "enum": ["fact", "preference", "rule", "note"], "description": "fact=Tatsache, preference=Vorliebe, note=Notiz"}, - "content": {"type": "string", "description": "Der Fakt (kurz, 3. Person, z.B. 'Fliegt naechste Woche nach Frankfurt')"}, - "memory_type": {"type": "string", "enum": ["temporary", "permanent"], "description": "temporary=zeitlich begrenzt (Reisen, Termine), permanent=dauerhaft (Praeferenzen, Rollen, Regeln)"}, - "expires_at": {"type": "string", "description": "Nur bei temporary: Zeitangabe wann es ablaeuft (z.B. 'naechste Woche', 'morgen', 'am Freitag', '22.03.2026'). Leer lassen wenn unklar."}, + "kind": {"type": "string", "enum": ["fact", "preference", "rule", "note"], "description": "Grobe Kategorie"}, + "content": {"type": "string", "description": "Der Fakt (kurz, 3. Person, z.B. 'Lieblingskaffee ist Flat White')"}, + "memory_type": {"type": "string", "enum": ["fact", "preference", "relationship", "plan", "temporary", "uncertain"], "description": "fact=stabiler Fakt, preference=Vorliebe, relationship=Beziehung/Rolle, plan=Vorhaben mit Zeitbezug, temporary=kurzfristiger Zustand, uncertain=vage Aussage"}, + "confidence": {"type": "string", "enum": ["high", "medium", "low"], "description": "high=klare Aussage, medium=wahrscheinlich, low=vage"}, + "expires_at": {"type": "string", "description": "Bei plan/temporary: Zeitangabe wann es ablaeuft (z.B. 'naechste Woche', 'morgen', '22.03.2026'). Leer bei dauerhaften Fakten."}, }, - "required": ["scope", "kind", "content", "memory_type"], + "required": ["scope", "kind", "content", "memory_type", "confidence"], }, }, }, @@ -402,7 +405,7 @@ def ask_with_tools(question: str, tool_handlers: dict, session_id: str = None) - try: import memory_client - memory_items = memory_client.get_active_memory() + memory_items = memory_client.get_relevant_memory(question, top_k=10) memory_block = memory_client.format_memory_for_prompt(memory_items) except Exception: memory_block = "" diff --git a/homelab-ai-bot/memory_client.py b/homelab-ai-bot/memory_client.py index 760eed2a..155c2cac 100644 --- a/homelab-ai-bot/memory_client.py +++ b/homelab-ai-bot/memory_client.py @@ -248,7 +248,7 @@ def delete_candidate(item_id: int) -> bool: def get_active_memory() -> list[dict]: - """Holt alle aktiven Memory-Items fuer den System-Prompt (ohne abgelaufene).""" + """Holt alle aktiven Memory-Items (ohne abgelaufene). Fuer /memory-Befehl.""" result = _get("/memory", {"status": "active", "limit": 100}) if not result or "items" not in result: return [] @@ -263,26 +263,32 @@ def get_active_memory() -> list[dict]: return items +def get_relevant_memory(query: str, top_k: int = 10) -> list[dict]: + """RAG-Suche: Holt die relevantesten Memory-Items per Vektor-Aehnlichkeit.""" + result = _get("/memory/search", {"q": query, "top_k": top_k, "status": "active"}) + if not result or "items" not in result: + return get_active_memory()[:top_k] + return result["items"] + + def format_memory_for_prompt(items: list[dict]) -> str: """Formatiert Memory-Items als Text-Block fuer den System-Prompt.""" if not items: return "" - permanent = [i for i in items if i.get("memory_type") != "temporary"] - temporary = [i for i in items if i.get("memory_type") == "temporary"] - - lines = ["", "=== GEDAECHTNIS ==="] - if permanent: - lines.append("-- Dauerhaft --") - for item in permanent: - lines.append(f"[{item['scope']}/{item['kind']}] {item['content']}") - if temporary: - lines.append("-- Temporaer --") - for item in temporary: - exp = item.get("expires_at") - exp_str = "" - if exp: - exp_str = " (bis " + datetime.fromtimestamp(exp).strftime("%d.%m.%Y") + ")" - lines.append(f"[{item['scope']}/{item['kind']}] {item['content']}{exp_str}") + TYPE_ICON = {"fact": "📌", "preference": "⭐", "relationship": "👤", "plan": "📅", "temporary": "🕒", "uncertain": "❓"} + lines = ["", "=== GEDAECHTNIS (relevante Fakten) ==="] + for item in items: + mtype = item.get("memory_type", "fact") + icon = TYPE_ICON.get(mtype, "•") + conf = item.get("confidence", "high") + exp = item.get("expires_at") + parts = [f"{icon} [{mtype}]"] + if conf != "high": + parts.append(f"({conf})") + parts.append(item["content"]) + if exp: + parts.append(f"(bis {datetime.fromtimestamp(exp).strftime('%d.%m.%Y')})") + lines.append(" ".join(parts)) lines.append("=== ENDE GEDAECHTNIS ===") return "\n".join(lines) diff --git a/homelab-ai-bot/telegram_bot.py b/homelab-ai-bot/telegram_bot.py index dc0dbbca..9d82fc7b 100644 --- a/homelab-ai-bot/telegram_bot.py +++ b/homelab-ai-bot/telegram_bot.py @@ -322,22 +322,30 @@ async def cmd_memory(update: Update, ctx: ContextTypes.DEFAULT_TYPE): if not items: await update.message.reply_text("Kein Gedaechtnis vorhanden.") return - permanent = [i for i in items if i.get("memory_type") != "temporary"] - temporary = [i for i in items if i.get("memory_type") == "temporary"] + TYPE_ICON = {"fact": "📌", "preference": "⭐", "relationship": "👤", "plan": "📅", "temporary": "🕒", "uncertain": "❓"} + CONF_ICON = {"high": "", "medium": " ⚠", "low": " ❔"} + groups = {} + for i in items: + mt = i.get("memory_type", "fact") + groups.setdefault(mt, []).append(i) lines = [f"🧠 Gedaechtnis: {len(items)} Eintraege\n"] - if permanent: - lines.append(f"📌 Dauerhaft ({len(permanent)}):") - for i in permanent: - lines.append(f" • {i['content'][:100]}") - if temporary: - lines.append(f"\n🕒 Temporaer ({len(temporary)}):") - for i in temporary: + for mtype in ("fact", "preference", "relationship", "plan", "temporary", "uncertain"): + group = groups.get(mtype, []) + if not group: + continue + icon = TYPE_ICON.get(mtype, "•") + lines.append(f"{icon} {mtype.upper()} ({len(group)}):") + for i in group: + conf = CONF_ICON.get(i.get("confidence", "high"), "") + src = i.get("source_type", "") + src_tag = f" [{src}]" if src and src != "manual" else "" exp = i.get("expires_at") exp_str = "" if exp: from datetime import datetime exp_str = f" (bis {datetime.fromtimestamp(exp).strftime('%d.%m.%Y')})" - lines.append(f" • {i['content'][:100]}{exp_str}") + lines.append(f" • {i['content'][:90]}{conf}{exp_str}{src_tag}") + lines.append("") text = "\n".join(lines) await update.message.reply_text(text[:4000], reply_markup=KEYBOARD) @@ -368,7 +376,8 @@ async def handle_voice(update: Update, ctx: ContextTypes.DEFAULT_TYPE): channel_key = str(update.effective_chat.id) session_id = memory_client.get_or_create_session(channel_key, source="telegram") - context.last_suggest_result = {"type": None, "candidate_id": None} + context.last_suggest_result = {"type": None} + context.set_source_type("telegram_voice") handlers = context.get_tool_handlers(session_id=session_id) answer = llm.ask_with_tools(text, handlers, session_id=session_id) if session_id: @@ -416,6 +425,7 @@ async def handle_message(update: Update, ctx: ContextTypes.DEFAULT_TYPE): await update.message.reply_text("🤔 Denke nach...") try: context.last_suggest_result = {"type": None} + context.set_source_type("telegram_text") handlers = context.get_tool_handlers(session_id=session_id) answer = llm.ask_with_tools(text, handlers, session_id=session_id) if session_id: