diff --git a/homelab-ai-bot/context.py b/homelab-ai-bot/context.py index a2259f32..87255abd 100644 --- a/homelab-ai-bot/context.py +++ b/homelab-ai-bot/context.py @@ -294,19 +294,37 @@ def _tool_memory_read(scope=""): return "\n".join(lines) -def _tool_memory_suggest(scope, kind, content): +def _tool_memory_suggest(scope, kind, content, memory_type="temporary", expires_at=None): import memory_client - result = memory_client._post("/memory", { + if memory_type not in ("temporary", "permanent"): + memory_type = "temporary" + + exp_epoch = None + if memory_type == "temporary": + if expires_at: + exp_epoch = memory_client.parse_expires_from_text(expires_at) + if not exp_epoch: + exp_epoch = memory_client.parse_expires_from_text(content) + if not exp_epoch: + exp_epoch = memory_client.default_expires() + + data = { "scope": scope, "kind": kind, "content": content, "source": "bot-suggest", "status": "candidate", - }) + "memory_type": memory_type, + } + if exp_epoch: + data["expires_at"] = exp_epoch + + result = memory_client._post("/memory", data) if result and result.get("duplicate"): return f"Bereits gespeichert (ID {result.get('existing_id')})." if result and result.get("ok"): - return f"Vorschlag gespeichert als Kandidat (Fingerprint: {result.get('fingerprint', '?')[:12]}...)." + type_label = "temporaer" if memory_type == "temporary" else "dauerhaft" + return f"Vorschlag gespeichert als Kandidat ({type_label})." return "Konnte Vorschlag nicht speichern." @@ -352,7 +370,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: _tool_memory_suggest(scope, kind, content), + "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), "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 4f51676f..3104c423 100644 --- a/homelab-ai-bot/llm.py +++ b/homelab-ai-bot/llm.py @@ -25,12 +25,24 @@ STIL: - Emojis nur wenn sie Information tragen. Telegram-Format (kein Markdown). GEDAECHTNIS — memory_suggest: -Du MUSST memory_suggest aufrufen wenn der User etwas sagt das spaeter nuetzlich ist: +Du MUSST memory_suggest aufrufen wenn der User etwas sagt das spaeter nuetzlich ist. +Dabei IMMER memory_type angeben: + +TEMPORARY (memory_type="temporary") — hat ein Ablaufdatum: - Reiseplaene ("fliege nach...", "bin naechste Woche in...") -- Zeitliche Plaene ("Montag mache ich...", "ab Mai...") -- Neue stabile Fakten ("mein neuer Server...", "IP hat sich geaendert...") -- Projektstatus ("Jarvis ist jetzt aktiv", "Flugscanner laeuft wieder") -- Vorlieben/Korrekturen ("nenn mich...", "ich bevorzuge...") +- 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"). + +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") + +Im Zweifel: lieber temporary als permanent. Nach dem Aufruf sagst du kurz: "Notiert." — kein langes Erklaeren. NICHT speichern: Passwoerter, Tokens, Smalltalk, Hoeflichkeiten, reine Fragen. @@ -286,15 +298,17 @@ 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. Beispiele: 'Ich fliege nach X', 'Ab Mai nutze ich Y', 'Mein neuer Server heisst Z'. NICHT fuer Smalltalk, Fragen oder Passwoerter.", + "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.", "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."}, }, - "required": ["scope", "kind", "content"], + "required": ["scope", "kind", "content", "memory_type"], }, }, }, diff --git a/homelab-ai-bot/memory_client.py b/homelab-ai-bot/memory_client.py index a26e75e6..760eed2a 100644 --- a/homelab-ai-bot/memory_client.py +++ b/homelab-ai-bot/memory_client.py @@ -5,8 +5,10 @@ Kein Import von Bot- oder LLM-Logik — reiner HTTP-Client. """ import logging +import re import time import uuid +from datetime import datetime, timedelta from typing import Optional import requests @@ -155,6 +157,67 @@ def log_message(session_id: str, role: str, content: str, source: str = None, me _post(f"/sessions/{session_id}/messages", data) +DEFAULT_TEMP_DAYS = 14 +_WEEKDAYS_DE = { + "montag": 0, "dienstag": 1, "mittwoch": 2, "donnerstag": 3, + "freitag": 4, "samstag": 5, "sonntag": 6, +} + + +def parse_expires_from_text(text: str) -> Optional[int]: + """Versucht aus deutschem Text ein absolutes Ablaufdatum (epoch) zu extrahieren. + Gibt None zurueck wenn keine belastbare Zeit erkannt wird.""" + t = text.lower().strip() + now = datetime.now() + + if "morgen" in t and "uebermorgen" not in t and "übermorgen" not in t: + dt = now + timedelta(days=1) + return int(dt.replace(hour=23, minute=59, second=59).timestamp()) + if "uebermorgen" in t or "übermorgen" in t: + dt = now + timedelta(days=2) + return int(dt.replace(hour=23, minute=59, second=59).timestamp()) + + for day_name, day_num in _WEEKDAYS_DE.items(): + if day_name in t: + days_ahead = (day_num - now.weekday()) % 7 + if days_ahead == 0: + days_ahead = 7 + dt = now + timedelta(days=days_ahead) + return int(dt.replace(hour=23, minute=59, second=59).timestamp()) + + if "naechste woche" in t or "nächste woche" in t or "naechster woche" in t or "nächster woche" in t: + dt = now + timedelta(days=7) + return int(dt.replace(hour=23, minute=59, second=59).timestamp()) + if "naechsten monat" in t or "nächsten monat" in t: + dt = now + timedelta(days=30) + return int(dt.replace(hour=23, minute=59, second=59).timestamp()) + if "heute" in t: + return int(now.replace(hour=23, minute=59, second=59).timestamp()) + + m = re.search(r"(\d{1,2})\.(\d{1,2})\.(\d{4})", t) + if m: + try: + dt = datetime(int(m.group(3)), int(m.group(2)), int(m.group(1)), 23, 59, 59) + return int(dt.timestamp()) + except ValueError: + pass + + m = re.search(r"(\d{4})-(\d{2})-(\d{2})", t) + if m: + try: + dt = datetime(int(m.group(1)), int(m.group(2)), int(m.group(3)), 23, 59, 59) + return int(dt.timestamp()) + except ValueError: + pass + + return None + + +def default_expires() -> int: + """Konservatives Default-Ablaufdatum fuer temporaere Items ohne erkannte Zeitangabe.""" + return int((datetime.now() + timedelta(days=DEFAULT_TEMP_DAYS)).timestamp()) + + def get_candidates() -> list[dict]: """Holt alle offenen Memory-Kandidaten.""" result = _get("/memory", {"status": "candidate", "limit": 20}) @@ -163,9 +226,12 @@ def get_candidates() -> list[dict]: return [] -def activate_candidate(item_id: int) -> bool: - """Setzt einen Kandidaten auf aktiv.""" - result = _patch(f"/memory/{item_id}", {"status": "active"}) +def activate_candidate(item_id: int, memory_type: str = "permanent", expires_at: int = None) -> bool: + """Setzt einen Kandidaten auf aktiv mit Typ.""" + data = {"status": "active", "memory_type": memory_type} + if expires_at: + data["expires_at"] = expires_at + result = _patch(f"/memory/{item_id}", data) return bool(result and result.get("ok")) @@ -182,21 +248,41 @@ def delete_candidate(item_id: int) -> bool: def get_active_memory() -> list[dict]: - """Holt alle aktiven Memory-Items fuer den System-Prompt.""" + """Holt alle aktiven Memory-Items fuer den System-Prompt (ohne abgelaufene).""" result = _get("/memory", {"status": "active", "limit": 100}) - if result and "items" in result: - return result["items"] - return [] + if not result or "items" not in result: + return [] + now_ts = int(time.time()) + items = [] + for item in result["items"]: + exp = item.get("expires_at") + if exp and exp < now_ts: + _patch(f"/memory/{item['id']}", {"status": "archived"}) + continue + items.append(item) + return items def format_memory_for_prompt(items: list[dict]) -> str: """Formatiert Memory-Items als Text-Block fuer den System-Prompt.""" if not items: return "" - lines = ["", "=== GEDAECHTNIS (persistente Fakten) ==="] - for item in items: - prefix = f"[{item['scope']}/{item['kind']}]" - lines.append(f"{prefix} {item['content']}") + 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}") 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 983bd944..414aad57 100644 --- a/homelab-ai-bot/telegram_bot.py +++ b/homelab-ai-bot/telegram_bot.py @@ -288,11 +288,23 @@ async def cmd_feeds(update: Update, ctx: ContextTypes.DEFAULT_TYPE): def _memory_buttons(item_id: int) -> InlineKeyboardMarkup: return InlineKeyboardMarkup([[ - InlineKeyboardButton("✅ Merken", callback_data=f"mem_activate:{item_id}"), + InlineKeyboardButton("🕒 Temporär", callback_data=f"mem_temp:{item_id}"), + InlineKeyboardButton("📌 Dauerhaft", callback_data=f"mem_perm:{item_id}"), InlineKeyboardButton("❌ Verwerfen", callback_data=f"mem_delete:{item_id}"), ]]) +def _format_candidate(item: dict) -> str: + mtype = item.get("memory_type") or "?" + type_icon = "🕒" if mtype == "temporary" else "📌" if mtype == "permanent" else "❓" + line = f"{type_icon} [{item['scope']}/{item['kind']}] {mtype}\n{item['content']}" + exp = item.get("expires_at") + if exp: + from datetime import datetime + line += f"\n⏰ Ablauf: {datetime.fromtimestamp(exp).strftime('%d.%m.%Y %H:%M')}" + return line + + async def cmd_memory(update: Update, ctx: ContextTypes.DEFAULT_TYPE): if not _authorized(update): return @@ -301,7 +313,7 @@ async def cmd_memory(update: Update, ctx: ContextTypes.DEFAULT_TYPE): await update.message.reply_text("Keine offenen Kandidaten.") return for item in candidates: - text = f"📝 [{item['scope']}/{item['kind']}]\n{item['content']}" + text = _format_candidate(item) await update.message.reply_text(text, reply_markup=_memory_buttons(item["id"])) @@ -309,14 +321,32 @@ async def handle_memory_callback(update: Update, ctx: ContextTypes.DEFAULT_TYPE) query = update.callback_query await query.answer() data = query.data or "" + content_preview = query.message.text.split("\n", 2)[-1][:120] if query.message.text else "" - if data.startswith("mem_activate:"): + if data.startswith("mem_temp:"): item_id = int(data.split(":")[1]) - ok = memory_client.activate_candidate(item_id) + candidates = memory_client.get_candidates() + item = next((c for c in candidates if c["id"] == item_id), None) + exp = None + if item: + exp = item.get("expires_at") or memory_client.parse_expires_from_text(item.get("content", "")) + if not exp: + exp = memory_client.default_expires() + ok = memory_client.activate_candidate(item_id, memory_type="temporary", expires_at=exp) if ok: - await query.edit_message_text("✅ Aktiviert: " + query.message.text.split("\n", 1)[-1]) + from datetime import datetime + exp_str = datetime.fromtimestamp(exp).strftime("%d.%m.%Y") + await query.edit_message_text(f"🕒 Temporär gespeichert (bis {exp_str}): {content_preview}") else: - await query.edit_message_text("Fehler beim Aktivieren.") + await query.edit_message_text("Fehler beim Speichern.") + + elif data.startswith("mem_perm:"): + item_id = int(data.split(":")[1]) + ok = memory_client.activate_candidate(item_id, memory_type="permanent") + if ok: + await query.edit_message_text(f"📌 Dauerhaft gespeichert: {content_preview}") + else: + await query.edit_message_text("Fehler beim Speichern.") elif data.startswith("mem_delete:"): item_id = int(data.split(":")[1]) @@ -366,8 +396,9 @@ async def handle_message(update: Update, ctx: ContextTypes.DEFAULT_TYPE): if new_candidates: c = new_candidates[0] + type_icon = "🕒" if c.get("memory_type") == "temporary" else "📌" if c.get("memory_type") == "permanent" else "📝" await update.message.reply_text( - answer[:4000] + "\n\n📝 " + c["content"], + answer[:4000] + "\n\n" + type_icon + " " + c["content"], reply_markup=_memory_buttons(c["id"]), ) else: