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)
|
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.",
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue