Memory: temporaer/permanent Trennung, 3 Inline-Buttons, Zeitnormalisierung, expires_at
This commit is contained in:
parent
6be51835bf
commit
d834d12520
4 changed files with 179 additions and 30 deletions
|
|
@ -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.",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue