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)
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.",
}

View file

@ -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"],
},
},
},

View file

@ -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"]
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)

View file

@ -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: