Memory: temporaer/permanent Trennung, 3 Inline-Buttons, Zeitnormalisierung, expires_at

This commit is contained in:
root 2026-03-15 13:07:56 +07:00
parent 6be51835bf
commit d834d12520
4 changed files with 179 additions and 30 deletions

View file

@ -294,19 +294,37 @@ def _tool_memory_read(scope=""):
return "\n".join(lines) 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 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, "scope": scope,
"kind": kind, "kind": kind,
"content": content, "content": content,
"source": "bot-suggest", "source": "bot-suggest",
"status": "candidate", "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"): if result and result.get("duplicate"):
return f"Bereits gespeichert (ID {result.get('existing_id')})." return f"Bereits gespeichert (ID {result.get('existing_id')})."
if result and result.get("ok"): 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." 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_todays_mails": lambda: _tool_get_todays_mails(),
"get_smart_mail_digest": lambda hours=24: _tool_get_smart_mail_digest(hours=hours), "get_smart_mail_digest": lambda hours=24: _tool_get_smart_mail_digest(hours=hours),
"memory_read": lambda scope="": _tool_memory_read(scope), "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_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.", "session_summary": lambda topic="": _tool_session_summary(session_id, topic=topic) if session_id else "Keine Session aktiv.",
} }

View file

@ -25,12 +25,24 @@ STIL:
- Emojis nur wenn sie Information tragen. Telegram-Format (kein Markdown). - Emojis nur wenn sie Information tragen. Telegram-Format (kein Markdown).
GEDAECHTNIS memory_suggest: 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...") - Reiseplaene ("fliege nach...", "bin naechste Woche in...")
- Zeitliche Plaene ("Montag mache ich...", "ab Mai...") - Termine ("morgen 14 Uhr Zahnarzt", "am Freitag Meeting")
- Neue stabile Fakten ("mein neuer Server...", "IP hat sich geaendert...") - Kurzfristige Aufenthalte ("bin bis Mittwoch in Berlin")
- Projektstatus ("Jarvis ist jetzt aktiv", "Flugscanner laeuft wieder") - Einmalige Vorhaben ("will diese Woche den Server migrieren")
- Vorlieben/Korrekturen ("nenn mich...", "ich bevorzuge...") 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. Nach dem Aufruf sagst du kurz: "Notiert." kein langes Erklaeren.
NICHT speichern: Passwoerter, Tokens, Smalltalk, Hoeflichkeiten, reine Fragen. NICHT speichern: Passwoerter, Tokens, Smalltalk, Hoeflichkeiten, reine Fragen.
@ -286,15 +298,17 @@ TOOLS = [
"type": "function", "type": "function",
"function": { "function": {
"name": "memory_suggest", "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": { "parameters": {
"type": "object", "type": "object",
"properties": { "properties": {
"scope": {"type": "string", "enum": ["user", "environment", "project"], "description": "user=persoenlich, environment=Infrastruktur, project=Projekt"}, "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"}, "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')"}, "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"],
}, },
}, },
}, },

View file

@ -5,8 +5,10 @@ Kein Import von Bot- oder LLM-Logik — reiner HTTP-Client.
""" """
import logging import logging
import re
import time import time
import uuid import uuid
from datetime import datetime, timedelta
from typing import Optional from typing import Optional
import requests 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) _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]: def get_candidates() -> list[dict]:
"""Holt alle offenen Memory-Kandidaten.""" """Holt alle offenen Memory-Kandidaten."""
result = _get("/memory", {"status": "candidate", "limit": 20}) result = _get("/memory", {"status": "candidate", "limit": 20})
@ -163,9 +226,12 @@ def get_candidates() -> list[dict]:
return [] return []
def activate_candidate(item_id: int) -> bool: def activate_candidate(item_id: int, memory_type: str = "permanent", expires_at: int = None) -> bool:
"""Setzt einen Kandidaten auf aktiv.""" """Setzt einen Kandidaten auf aktiv mit Typ."""
result = _patch(f"/memory/{item_id}", {"status": "active"}) 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")) return bool(result and result.get("ok"))
@ -182,21 +248,41 @@ def delete_candidate(item_id: int) -> bool:
def get_active_memory() -> list[dict]: 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}) result = _get("/memory", {"status": "active", "limit": 100})
if result and "items" in result: if not result or "items" not in result:
return result["items"]
return [] 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: def format_memory_for_prompt(items: list[dict]) -> str:
"""Formatiert Memory-Items als Text-Block fuer den System-Prompt.""" """Formatiert Memory-Items als Text-Block fuer den System-Prompt."""
if not items: if not items:
return "" return ""
lines = ["", "=== GEDAECHTNIS (persistente Fakten) ==="] permanent = [i for i in items if i.get("memory_type") != "temporary"]
for item in items: temporary = [i for i in items if i.get("memory_type") == "temporary"]
prefix = f"[{item['scope']}/{item['kind']}]"
lines.append(f"{prefix} {item['content']}") 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 ===") lines.append("=== ENDE GEDAECHTNIS ===")
return "\n".join(lines) return "\n".join(lines)

View file

@ -288,11 +288,23 @@ async def cmd_feeds(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
def _memory_buttons(item_id: int) -> InlineKeyboardMarkup: def _memory_buttons(item_id: int) -> InlineKeyboardMarkup:
return 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}"), 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): async def cmd_memory(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
if not _authorized(update): if not _authorized(update):
return return
@ -301,7 +313,7 @@ async def cmd_memory(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
await update.message.reply_text("Keine offenen Kandidaten.") await update.message.reply_text("Keine offenen Kandidaten.")
return return
for item in candidates: 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"])) 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 query = update.callback_query
await query.answer() await query.answer()
data = query.data or "" 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]) 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: 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: 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:"): elif data.startswith("mem_delete:"):
item_id = int(data.split(":")[1]) item_id = int(data.split(":")[1])
@ -366,8 +396,9 @@ async def handle_message(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
if new_candidates: if new_candidates:
c = new_candidates[0] 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( 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"]), reply_markup=_memory_buttons(c["id"]),
) )
else: else: